From 7b2d58818efcca1cb9756dfc397203c702c3f812 Mon Sep 17 00:00:00 2001 From: Fredrik Ahlgren Date: Wed, 14 Oct 2026 12:59:50 +0200 Subject: [PATCH 1/2] Apple Watch: Now, the plan dial on the Crown, the car, the price and complications (W1) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A watch app and its complications, reading and commanding through the iPhone. watchOS gives an ordinary app no WebSocket (TN3135), so the phone hands the watch the records its widgets read after each cache write, sends Now's frames while the watch looks, and runs each command over its own session. FTW Cloud never controls. - FTWWatch: Now (lit only while a live frame is fresh, ink with its time otherwise), the plan dial with the Crown stepping by quarter hour, the car with Charge now, Pause and Back to the plan, the price, and the mode. Each command shows SENT ▸ ACCEPTED ▸ CONFIRMED from the box's answers. - FTWWatchWidgets: the dial as accessoryCircular, plus rectangular, corner and inline, and a RelevanceConfiguration card for the cheapest hours. - Built to stand alone: its own cache, its own key, its own cmdId, and two seams (ReadPath, CommandPath). PhonePath is the only path today. - Phone side: WatchBridge in the app, WatchShare in FTWKit, the wire in FTWShared/Watch. Session.command takes the watch's cmdId. - Each colour set gains a watch variant equal to its dark one, since watchOS compiles no dark appearance; ThemeTests holds them equal. Co-Authored-By: Claude Opus 5.5 --- .github/workflows/apple.yml | 4 +- .github/workflows/ios-release.yml | 8 +- CLAUDE.md | 22 +- appleApp/FTW.xcconfig | 10 +- appleApp/FTW.xcodeproj/project.pbxproj | 418 ++++++++++++++++++ .../xcshareddata/xcschemes/FTWWatch.xcscheme | 89 ++++ appleApp/FTW/FTWApp.swift | 9 +- appleApp/FTW/Intents/IntentRunner.swift | 9 + appleApp/FTW/Watch/WatchBridge.swift | 321 ++++++++++++++ .../ftw.alarm.colorset/Contents.json | 12 + .../ftw.car.colorset/Contents.json | 12 + .../ftw.gives.colorset/Contents.json | 12 + .../ftw.grid.colorset/Contents.json | 12 + .../ftw.ground.colorset/Contents.json | 12 + .../ftw.hairline.colorset/Contents.json | 12 + .../ftw.ink.colorset/Contents.json | 12 + .../ftw.ink2.colorset/Contents.json | 12 + .../ftw.ink3.colorset/Contents.json | 12 + .../ftw.live.colorset/Contents.json | 12 + .../ftw.raised.colorset/Contents.json | 12 + .../ftw.solar.colorset/Contents.json | 12 + .../ftw.stores.colorset/Contents.json | 12 + appleApp/FTWKit/Package.swift | 2 +- .../Sources/FTWKit/Protocol/Session.swift | 8 +- .../FTWKit/State/LoadpointsModel.swift | 24 +- .../Sources/FTWKit/State/ModeModel.swift | 8 +- .../Sources/FTWKit/State/WatchShare.swift | 330 ++++++++++++++ .../Resources/en.lproj/Watch.strings | 1 + .../Resources/sv.lproj/Watch.strings | 79 ++++ .../Sources/FTWShared/Watch/WatchDial.swift | 202 +++++++++ .../Sources/FTWShared/Watch/WatchGlance.swift | 263 +++++++++++ .../Sources/FTWShared/Watch/WatchWire.swift | 338 ++++++++++++++ .../Sources/FTWShared/Watch/WatchWords.swift | 106 +++++ .../Tests/FTWKitTests/RemoteTests.swift | 3 +- .../FTWKit/Tests/FTWKitTests/ThemeTests.swift | 14 +- .../Tests/FTWKitTests/WatchShareTests.swift | 108 +++++ .../Tests/FTWSharedTests/WatchTests.swift | 325 ++++++++++++++ appleApp/FTWWatch-Info.plist | 13 + appleApp/FTWWatch.entitlements | 10 + .../FTWWatch/AppIcon.icon/Assets/bolt.svg | 4 + appleApp/FTWWatch/AppIcon.icon/icon.json | 45 ++ appleApp/FTWWatch/CarPage.swift | 114 +++++ appleApp/FTWWatch/FTWWatchApp.swift | 73 +++ appleApp/FTWWatch/Paths/Paths.swift | 45 ++ appleApp/FTWWatch/Paths/PhonePath.swift | 100 +++++ appleApp/FTWWatch/Phosphor.swift | 128 ++++++ appleApp/FTWWatch/PlanPage.swift | 122 +++++ appleApp/FTWWatch/PricePage.swift | 59 +++ appleApp/FTWWatch/PrivacyInfo.xcprivacy | 31 ++ appleApp/FTWWatch/WatchCache.swift | 48 ++ appleApp/FTWWatch/WatchIdentity.swift | 52 +++ appleApp/FTWWatch/WatchModel.swift | 168 +++++++ appleApp/FTWWatch/WatchRoot.swift | 217 +++++++++ appleApp/FTWWatchShared/BlockBars.swift | 35 ++ appleApp/FTWWatchShared/DialFace.swift | 155 +++++++ appleApp/FTWWatchShared/WatchKinds.swift | 6 + appleApp/FTWWatchUITests/WatchTour.swift | 98 ++++ appleApp/FTWWatchWidgets-Info.plist | 18 + appleApp/FTWWatchWidgets.entitlements | 10 + .../FTWWatchWidgets/CheapPowerWidget.swift | 97 ++++ .../FTWWatchWidgets/PlanComplication.swift | 128 ++++++ .../FTWWatchWidgets/PrivacyInfo.xcprivacy | 23 + appleApp/FTWWatchWidgets/WatchWidgets.swift | 31 ++ appleApp/RELEASING.md | 2 +- 64 files changed, 4653 insertions(+), 36 deletions(-) create mode 100644 appleApp/FTW.xcodeproj/xcshareddata/xcschemes/FTWWatch.xcscheme create mode 100644 appleApp/FTW/Watch/WatchBridge.swift create mode 100644 appleApp/FTWKit/Sources/FTWKit/State/WatchShare.swift create mode 100644 appleApp/FTWKit/Sources/FTWShared/Resources/en.lproj/Watch.strings create mode 100644 appleApp/FTWKit/Sources/FTWShared/Resources/sv.lproj/Watch.strings create mode 100644 appleApp/FTWKit/Sources/FTWShared/Watch/WatchDial.swift create mode 100644 appleApp/FTWKit/Sources/FTWShared/Watch/WatchGlance.swift create mode 100644 appleApp/FTWKit/Sources/FTWShared/Watch/WatchWire.swift create mode 100644 appleApp/FTWKit/Sources/FTWShared/Watch/WatchWords.swift create mode 100644 appleApp/FTWKit/Tests/FTWKitTests/WatchShareTests.swift create mode 100644 appleApp/FTWKit/Tests/FTWSharedTests/WatchTests.swift create mode 100644 appleApp/FTWWatch-Info.plist create mode 100644 appleApp/FTWWatch.entitlements create mode 100644 appleApp/FTWWatch/AppIcon.icon/Assets/bolt.svg create mode 100644 appleApp/FTWWatch/AppIcon.icon/icon.json create mode 100644 appleApp/FTWWatch/CarPage.swift create mode 100644 appleApp/FTWWatch/FTWWatchApp.swift create mode 100644 appleApp/FTWWatch/Paths/Paths.swift create mode 100644 appleApp/FTWWatch/Paths/PhonePath.swift create mode 100644 appleApp/FTWWatch/Phosphor.swift create mode 100644 appleApp/FTWWatch/PlanPage.swift create mode 100644 appleApp/FTWWatch/PricePage.swift create mode 100644 appleApp/FTWWatch/PrivacyInfo.xcprivacy create mode 100644 appleApp/FTWWatch/WatchCache.swift create mode 100644 appleApp/FTWWatch/WatchIdentity.swift create mode 100644 appleApp/FTWWatch/WatchModel.swift create mode 100644 appleApp/FTWWatch/WatchRoot.swift create mode 100644 appleApp/FTWWatchShared/BlockBars.swift create mode 100644 appleApp/FTWWatchShared/DialFace.swift create mode 100644 appleApp/FTWWatchShared/WatchKinds.swift create mode 100644 appleApp/FTWWatchUITests/WatchTour.swift create mode 100644 appleApp/FTWWatchWidgets-Info.plist create mode 100644 appleApp/FTWWatchWidgets.entitlements create mode 100644 appleApp/FTWWatchWidgets/CheapPowerWidget.swift create mode 100644 appleApp/FTWWatchWidgets/PlanComplication.swift create mode 100644 appleApp/FTWWatchWidgets/PrivacyInfo.xcprivacy create mode 100644 appleApp/FTWWatchWidgets/WatchWidgets.swift diff --git a/.github/workflows/apple.yml b/.github/workflows/apple.yml index a5ec938..13175c8 100644 --- a/.github/workflows/apple.yml +++ b/.github/workflows/apple.yml @@ -28,7 +28,9 @@ jobs: # 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 + # The iPhone app embeds the Apple Watch app, so this builds the watch + # app and its complications for the watchOS Simulator too. + - name: Build the app, its widgets and the watch app for the simulators working-directory: appleApp run: >- xcodebuild build -quiet -project FTW.xcodeproj -scheme FTW diff --git a/.github/workflows/ios-release.yml b/.github/workflows/ios-release.yml index adc3752..0d37225 100644 --- a/.github/workflows/ios-release.yml +++ b/.github/workflows/ios-release.yml @@ -127,12 +127,12 @@ jobs: 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 + # App Store Connect refuses an extension or a watch app whose version + # differs from its app's, so check them all before sending anything. + - name: Check the version in the app, its extensions and the watch app run: | app=$RUNNER_TEMP/FTW.xcarchive/Products/Applications/FTW.app - for plist in "$app/Info.plist" "$app"/PlugIns/*.appex/Info.plist; do + for plist in "$app/Info.plist" "$app"/PlugIns/*.appex/Info.plist "$app"/Watch/*.app/Info.plist "$app"/Watch/*.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; } diff --git a/CLAUDE.md b/CLAUDE.md index e9bbba9..049aba4 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -38,6 +38,18 @@ 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. +**Apple Watch (`appleApp/FTWWatch*`).** W1, asked for by Fredrik on +2026-10-14: Now, the plan dial (the Crown steps it by quarter hour), the car +and the price, Charge now, Pause, Back to the plan and the mode, and +complications. watchOS 26 is the minimum. watchOS gives an ordinary app no +WebSocket (TN3135), so in W1 the watch reads and commands through the iPhone +(`WatchBridge`, WatchConnectivity); FTW Cloud never controls. The app is +built to stand alone (`WKRunsIndependentlyOfCompanionApp`): it opens on its +own cache, makes its own key and its own `cmdId`, and reads and sends +through two seams, `ReadPath` and `CommandPath`. `PhonePath` is the only +path now; FTW Cloud reads and the relay's exchange (ADR 0009) come later. +An unlocked watch on the wrist counts as unlocked for commands. + **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 @@ -206,7 +218,9 @@ The rules below put these into practice. `ThemeTests` holds the contrast (text 4.5:1 on ground and raised; energy 4.5:1 in light and 7:1 in dark) and fails on any colour written in code. Increase Contrast turns ink3 into ink2 and hairlines into 2 px; Reduce - Transparency drops the glow. + Transparency drops the glow. watchOS compiles no dark appearance and a + watch is always dark, so each set also has a `watch` variant that equals + its dark one; `ThemeTests` holds them equal. - The 0.3 names in `FTW/UI/Theme.swift` (`fg`, `surface`, `accent` and the rest) point at these roles until each screen is rebuilt. New code reads the roles. @@ -271,7 +285,11 @@ On iPhone, one Swift package in `appleApp/FTWKit` holds three modules: 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 +pixel. `appleApp/FTWWidgets` owns the widgets. `appleApp/FTWWatch` is the +watch app, `FTWWatchWidgets` its complications and `FTWWatchShared` what +both draw (the dial). The watch links `FTWShared` and `FTWCharts`, never +FTWKit. What the phone and the watch say to each other lives in +`FTWShared/Watch/`, and the phone's side of it in `FTWKit`'s `WatchShare`. 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. diff --git a/appleApp/FTW.xcconfig b/appleApp/FTW.xcconfig index 51705f3..404c836 100644 --- a/appleApp/FTW.xcconfig +++ b/appleApp/FTW.xcconfig @@ -1,9 +1,11 @@ // 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). +// The App Group the app and widgets share. Written here once: every +// entitlements file and Info.plist reads it as $(FTW_APP_GROUP), and Swift +// reads it back from Info.plist (AppGroupFiles.infoKey). The Apple Watch +// app and its complications use the same name; an App Group stays on one +// device, so the watch holds its own copy, filled from the phone. FTW_APP_GROUP = group.energy.ftw.app // An extension's version must match its app's. @@ -11,6 +13,7 @@ MARKETING_VERSION = 0.3.5 CURRENT_PROJECT_VERSION = 1 IPHONEOS_DEPLOYMENT_TARGET = 26.0 +WATCHOS_DEPLOYMENT_TARGET = 26.0 // 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 @@ -22,6 +25,7 @@ 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*] = - +CODE_SIGN_IDENTITY[sdk=watchsimulator*] = - // 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). diff --git a/appleApp/FTW.xcodeproj/project.pbxproj b/appleApp/FTW.xcodeproj/project.pbxproj index 7df1443..4bfaf37 100644 --- a/appleApp/FTW.xcodeproj/project.pbxproj +++ b/appleApp/FTW.xcodeproj/project.pbxproj @@ -16,6 +16,10 @@ F7A000000000000000000034 /* FTWWidgets.appex in Embed Foundation Extensions */ = {isa = PBXBuildFile; fileRef = F7A000000000000000000040 /* FTWWidgets.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; }; F7A000000000000000000070 /* FTWShared in Frameworks */ = {isa = PBXBuildFile; productRef = F7A000000000000000000071 /* FTWShared */; }; F7A000000000000000000072 /* FTWNotify.appex in Embed Foundation Extensions */ = {isa = PBXBuildFile; fileRef = F7A000000000000000000074 /* FTWNotify.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; }; + F7A000000000000000000251 /* FTWWatch.app in Embed Watch Content */ = {isa = PBXBuildFile; fileRef = F7A000000000000000000200 /* FTWWatch.app */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; }; + F7A000000000000000000252 /* FTWWatchWidgets.appex in Embed Foundation Extensions */ = {isa = PBXBuildFile; fileRef = F7A000000000000000000201 /* FTWWatchWidgets.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; }; + F7A000000000000000000253 /* FTWShared in Frameworks */ = {isa = PBXBuildFile; productRef = F7A000000000000000000254 /* FTWShared */; }; + F7A000000000000000000255 /* FTWShared in Frameworks */ = {isa = PBXBuildFile; productRef = F7A000000000000000000256 /* FTWShared */; }; /* End PBXBuildFile section */ /* Begin PBXContainerItemProxy section */ @@ -40,6 +44,27 @@ remoteGlobalIDString = F7A000000000000000000078; remoteInfo = FTWNotify; }; + F7A000000000000000000260 /* PBXContainerItemProxy */ = { + isa = PBXContainerItemProxy; + containerPortal = F7A000000000000000000001 /* Project object */; + proxyType = 1; + remoteGlobalIDString = F7A000000000000000000220; + remoteInfo = FTWWatch; + }; + F7A000000000000000000262 /* PBXContainerItemProxy */ = { + isa = PBXContainerItemProxy; + containerPortal = F7A000000000000000000001 /* Project object */; + proxyType = 1; + remoteGlobalIDString = F7A000000000000000000230; + remoteInfo = FTWWatchWidgets; + }; + F7A000000000000000000264 /* PBXContainerItemProxy */ = { + isa = PBXContainerItemProxy; + containerPortal = F7A000000000000000000001 /* Project object */; + proxyType = 1; + remoteGlobalIDString = F7A000000000000000000220; + remoteInfo = FTWWatch; + }; /* End PBXContainerItemProxy section */ /* Begin PBXCopyFilesBuildPhase section */ @@ -68,6 +93,28 @@ name = "Copy the license texts"; runOnlyForDeploymentPostprocessing = 0; }; + F7A000000000000000000250 /* Embed Watch Content */ = { + isa = PBXCopyFilesBuildPhase; + buildActionMask = 2147483647; + dstPath = "$(CONTENTS_FOLDER_PATH)/Watch"; + dstSubfolderSpec = 16; + files = ( + F7A000000000000000000251 /* FTWWatch.app in Embed Watch Content */, + ); + name = "Embed Watch Content"; + runOnlyForDeploymentPostprocessing = 0; + }; + F7A000000000000000000224 /* Embed Foundation Extensions */ = { + isa = PBXCopyFilesBuildPhase; + buildActionMask = 2147483647; + dstPath = ""; + dstSubfolderSpec = 13; + files = ( + F7A000000000000000000252 /* FTWWatchWidgets.appex in Embed Foundation Extensions */, + ); + name = "Embed Foundation Extensions"; + runOnlyForDeploymentPostprocessing = 0; + }; /* End PBXCopyFilesBuildPhase section */ /* Begin PBXFileReference section */ @@ -85,6 +132,13 @@ F7A000000000000000000074 /* FTWNotify.appex */ = {isa = PBXFileReference; explicitFileType = "wrapper.app-extension"; includeInIndex = 0; path = FTWNotify.appex; sourceTree = BUILT_PRODUCTS_DIR; }; F7A000000000000000000075 /* FTWNotify.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = FTWNotify.entitlements; sourceTree = ""; }; F7A000000000000000000076 /* FTWNotify-Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = "FTWNotify-Info.plist"; sourceTree = ""; }; + F7A000000000000000000200 /* FTWWatch.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = FTWWatch.app; sourceTree = BUILT_PRODUCTS_DIR; }; + F7A000000000000000000201 /* FTWWatchWidgets.appex */ = {isa = PBXFileReference; explicitFileType = "wrapper.app-extension"; includeInIndex = 0; path = FTWWatchWidgets.appex; sourceTree = BUILT_PRODUCTS_DIR; }; + F7A000000000000000000202 /* FTWWatchUITests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = FTWWatchUITests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; + F7A000000000000000000203 /* FTWWatch.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = FTWWatch.entitlements; sourceTree = ""; }; + F7A000000000000000000204 /* FTWWatch-Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = "FTWWatch-Info.plist"; sourceTree = ""; }; + F7A000000000000000000205 /* FTWWatchWidgets.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = FTWWatchWidgets.entitlements; sourceTree = ""; }; + F7A000000000000000000206 /* FTWWatchWidgets-Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = "FTWWatchWidgets-Info.plist"; sourceTree = ""; }; /* End PBXFileReference section */ /* Begin PBXFileSystemSynchronizedRootGroup section */ @@ -113,6 +167,26 @@ path = FTWNotify; sourceTree = ""; }; + F7A000000000000000000210 /* FTWWatch */ = { + isa = PBXFileSystemSynchronizedRootGroup; + path = FTWWatch; + sourceTree = ""; + }; + F7A000000000000000000211 /* FTWWatchShared */ = { + isa = PBXFileSystemSynchronizedRootGroup; + path = FTWWatchShared; + sourceTree = ""; + }; + F7A000000000000000000212 /* FTWWatchWidgets */ = { + isa = PBXFileSystemSynchronizedRootGroup; + path = FTWWatchWidgets; + sourceTree = ""; + }; + F7A000000000000000000213 /* FTWWatchUITests */ = { + isa = PBXFileSystemSynchronizedRootGroup; + path = FTWWatchUITests; + sourceTree = ""; + }; /* End PBXFileSystemSynchronizedRootGroup section */ /* Begin PBXFrameworksBuildPhase section */ @@ -148,6 +222,29 @@ ); runOnlyForDeploymentPostprocessing = 0; }; + F7A000000000000000000222 /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + F7A000000000000000000253 /* FTWShared in Frameworks */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; + F7A000000000000000000232 /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + F7A000000000000000000255 /* FTWShared in Frameworks */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; + F7A000000000000000000242 /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; /* End PBXFrameworksBuildPhase section */ /* Begin PBXGroup section */ @@ -159,6 +256,10 @@ F7A000000000000000000101 /* FTWUITests */, F7A000000000000000000110 /* FTWCharts */, F7A000000000000000000077 /* FTWNotify */, + F7A000000000000000000210 /* FTWWatch */, + F7A000000000000000000211 /* FTWWatchShared */, + F7A000000000000000000212 /* FTWWatchWidgets */, + F7A000000000000000000213 /* FTWWatchUITests */, F7A000000000000000000008 /* FTW.xcconfig */, F7A000000000000000000006 /* FTW.entitlements */, F7A000000000000000000009 /* FTW-Info.plist */, @@ -166,6 +267,10 @@ F7A00000000000000000000A /* FTWWidgets-Info.plist */, F7A000000000000000000075 /* FTWNotify.entitlements */, F7A000000000000000000076 /* FTWNotify-Info.plist */, + F7A000000000000000000203 /* FTWWatch.entitlements */, + F7A000000000000000000204 /* FTWWatch-Info.plist */, + F7A000000000000000000205 /* FTWWatchWidgets.entitlements */, + F7A000000000000000000206 /* FTWWatchWidgets-Info.plist */, F7A000000000000000000063 /* License texts */, F7A000000000000000000004 /* Products */, ); @@ -188,6 +293,9 @@ F7A000000000000000000040 /* FTWWidgets.appex */, F7A000000000000000000100 /* FTWUITests.xctest */, F7A000000000000000000074 /* FTWNotify.appex */, + F7A000000000000000000200 /* FTWWatch.app */, + F7A000000000000000000201 /* FTWWatchWidgets.appex */, + F7A000000000000000000202 /* FTWWatchUITests.xctest */, ); name = Products; sourceTree = ""; @@ -205,12 +313,14 @@ F7A000000000000000000067 /* Copy the license texts */, F7A000000000000000000068 /* Record the source revision */, F7A000000000000000000036 /* Embed Foundation Extensions */, + F7A000000000000000000250 /* Embed Watch Content */, ); buildRules = ( ); dependencies = ( F7A000000000000000000037 /* PBXTargetDependency */, F7A00000000000000000007C /* PBXTargetDependency */, + F7A000000000000000000261 /* PBXTargetDependency */, ); fileSystemSynchronizedGroups = ( F7A000000000000000000003 /* FTW */, @@ -293,6 +403,79 @@ productReference = F7A000000000000000000074 /* FTWNotify.appex */; productType = "com.apple.product-type.app-extension"; }; + F7A000000000000000000220 /* FTWWatch */ = { + isa = PBXNativeTarget; + buildConfigurationList = F7A000000000000000000226 /* Build configuration list for PBXNativeTarget "FTWWatch" */; + buildPhases = ( + F7A000000000000000000221 /* Sources */, + F7A000000000000000000222 /* Frameworks */, + F7A000000000000000000223 /* Resources */, + F7A000000000000000000224 /* Embed Foundation Extensions */, + ); + buildRules = ( + ); + dependencies = ( + F7A000000000000000000263 /* PBXTargetDependency */, + ); + fileSystemSynchronizedGroups = ( + F7A000000000000000000210 /* FTWWatch */, + F7A000000000000000000211 /* FTWWatchShared */, + F7A000000000000000000110 /* FTWCharts */, + ); + name = FTWWatch; + packageProductDependencies = ( + F7A000000000000000000254 /* FTWShared */, + ); + productName = FTWWatch; + productReference = F7A000000000000000000200 /* FTWWatch.app */; + productType = "com.apple.product-type.application"; + }; + F7A000000000000000000230 /* FTWWatchWidgets */ = { + isa = PBXNativeTarget; + buildConfigurationList = F7A000000000000000000236 /* Build configuration list for PBXNativeTarget "FTWWatchWidgets" */; + buildPhases = ( + F7A000000000000000000231 /* Sources */, + F7A000000000000000000232 /* Frameworks */, + F7A000000000000000000233 /* Resources */, + ); + buildRules = ( + ); + dependencies = ( + ); + fileSystemSynchronizedGroups = ( + F7A000000000000000000212 /* FTWWatchWidgets */, + F7A000000000000000000211 /* FTWWatchShared */, + F7A000000000000000000110 /* FTWCharts */, + ); + name = FTWWatchWidgets; + packageProductDependencies = ( + F7A000000000000000000256 /* FTWShared */, + ); + productName = FTWWatchWidgets; + productReference = F7A000000000000000000201 /* FTWWatchWidgets.appex */; + productType = "com.apple.product-type.app-extension"; + }; + F7A000000000000000000240 /* FTWWatchUITests */ = { + isa = PBXNativeTarget; + buildConfigurationList = F7A000000000000000000246 /* Build configuration list for PBXNativeTarget "FTWWatchUITests" */; + buildPhases = ( + F7A000000000000000000241 /* Sources */, + F7A000000000000000000242 /* Frameworks */, + F7A000000000000000000243 /* Resources */, + ); + buildRules = ( + ); + dependencies = ( + F7A000000000000000000265 /* PBXTargetDependency */, + ); + fileSystemSynchronizedGroups = ( + F7A000000000000000000213 /* FTWWatchUITests */, + ); + name = FTWWatchUITests; + productName = FTWWatchUITests; + productReference = F7A000000000000000000202 /* FTWWatchUITests.xctest */; + productType = "com.apple.product-type.bundle.ui-testing"; + }; /* End PBXNativeTarget section */ /* Begin PBXProject section */ @@ -316,6 +499,16 @@ F7A000000000000000000078 = { CreatedOnToolsVersion = 27.0; }; + F7A000000000000000000220 = { + CreatedOnToolsVersion = 27.0; + }; + F7A000000000000000000230 = { + CreatedOnToolsVersion = 27.0; + }; + F7A000000000000000000240 = { + CreatedOnToolsVersion = 27.0; + TestTargetID = F7A000000000000000000220; + }; }; }; buildConfigurationList = F7A000000000000000000020 /* Build configuration list for PBXProject "FTW" */; @@ -340,6 +533,9 @@ F7A000000000000000000050 /* FTWWidgets */, F7A000000000000000000102 /* FTWUITests */, F7A000000000000000000078 /* FTWNotify */, + F7A000000000000000000220 /* FTWWatch */, + F7A000000000000000000230 /* FTWWatchWidgets */, + F7A000000000000000000240 /* FTWWatchUITests */, ); }; /* End PBXProject section */ @@ -373,6 +569,27 @@ ); runOnlyForDeploymentPostprocessing = 0; }; + F7A000000000000000000223 /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; + F7A000000000000000000233 /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; + F7A000000000000000000243 /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; /* End PBXResourcesBuildPhase section */ /* Begin PBXShellScriptBuildPhase section */ @@ -428,6 +645,27 @@ ); runOnlyForDeploymentPostprocessing = 0; }; + F7A000000000000000000221 /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; + F7A000000000000000000231 /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; + F7A000000000000000000241 /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; /* End PBXSourcesBuildPhase section */ /* Begin PBXTargetDependency section */ @@ -446,6 +684,21 @@ target = F7A000000000000000000078 /* FTWNotify */; targetProxy = F7A000000000000000000073 /* PBXContainerItemProxy */; }; + F7A000000000000000000261 /* PBXTargetDependency */ = { + isa = PBXTargetDependency; + target = F7A000000000000000000220 /* FTWWatch */; + targetProxy = F7A000000000000000000260 /* PBXContainerItemProxy */; + }; + F7A000000000000000000263 /* PBXTargetDependency */ = { + isa = PBXTargetDependency; + target = F7A000000000000000000230 /* FTWWatchWidgets */; + targetProxy = F7A000000000000000000262 /* PBXContainerItemProxy */; + }; + F7A000000000000000000265 /* PBXTargetDependency */ = { + isa = PBXTargetDependency; + target = F7A000000000000000000220 /* FTWWatch */; + targetProxy = F7A000000000000000000264 /* PBXContainerItemProxy */; + }; /* End PBXTargetDependency section */ /* Begin XCBuildConfiguration section */ @@ -690,6 +943,134 @@ }; name = Release; }; + F7A000000000000000000227 /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + CODE_SIGN_ENTITLEMENTS = FTWWatch.entitlements; + ENABLE_PREVIEWS = YES; + GENERATE_INFOPLIST_FILE = YES; + INFOPLIST_FILE = "FTWWatch-Info.plist"; + INFOPLIST_KEY_CFBundleDisplayName = FTW; + INFOPLIST_KEY_WKApplication = YES; + INFOPLIST_KEY_WKCompanionAppBundleIdentifier = energy.ftw.app; + INFOPLIST_KEY_WKRunsIndependentlyOfCompanionApp = YES; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + ); + PRODUCT_BUNDLE_IDENTIFIER = energy.ftw.app.watchkitapp; + PRODUCT_NAME = FTWWatch; + SDKROOT = watchos; + SKIP_INSTALL = YES; + SUPPORTED_PLATFORMS = "watchos watchsimulator"; + SWIFT_EMIT_LOC_STRINGS = YES; + SWIFT_VERSION = 6.0; + TARGETED_DEVICE_FAMILY = 4; + }; + name = Debug; + }; + F7A000000000000000000228 /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + CODE_SIGN_ENTITLEMENTS = FTWWatch.entitlements; + ENABLE_PREVIEWS = YES; + GENERATE_INFOPLIST_FILE = YES; + INFOPLIST_FILE = "FTWWatch-Info.plist"; + INFOPLIST_KEY_CFBundleDisplayName = FTW; + INFOPLIST_KEY_WKApplication = YES; + INFOPLIST_KEY_WKCompanionAppBundleIdentifier = energy.ftw.app; + INFOPLIST_KEY_WKRunsIndependentlyOfCompanionApp = YES; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + ); + PRODUCT_BUNDLE_IDENTIFIER = energy.ftw.app.watchkitapp; + PRODUCT_NAME = FTWWatch; + SDKROOT = watchos; + SKIP_INSTALL = YES; + SUPPORTED_PLATFORMS = "watchos watchsimulator"; + SWIFT_EMIT_LOC_STRINGS = YES; + SWIFT_VERSION = 6.0; + TARGETED_DEVICE_FAMILY = 4; + }; + name = Release; + }; + F7A000000000000000000237 /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + CODE_SIGN_ENTITLEMENTS = FTWWatchWidgets.entitlements; + GENERATE_INFOPLIST_FILE = YES; + INFOPLIST_FILE = "FTWWatchWidgets-Info.plist"; + INFOPLIST_KEY_CFBundleDisplayName = FTW; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + "@executable_path/../../Frameworks", + ); + PRODUCT_BUNDLE_IDENTIFIER = energy.ftw.app.watchkitapp.widgets; + PRODUCT_NAME = FTWWatchWidgets; + SDKROOT = watchos; + SKIP_INSTALL = YES; + SUPPORTED_PLATFORMS = "watchos watchsimulator"; + SWIFT_EMIT_LOC_STRINGS = YES; + SWIFT_VERSION = 6.0; + TARGETED_DEVICE_FAMILY = 4; + }; + name = Debug; + }; + F7A000000000000000000238 /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + CODE_SIGN_ENTITLEMENTS = FTWWatchWidgets.entitlements; + GENERATE_INFOPLIST_FILE = YES; + INFOPLIST_FILE = "FTWWatchWidgets-Info.plist"; + INFOPLIST_KEY_CFBundleDisplayName = FTW; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + "@executable_path/../../Frameworks", + ); + PRODUCT_BUNDLE_IDENTIFIER = energy.ftw.app.watchkitapp.widgets; + PRODUCT_NAME = FTWWatchWidgets; + SDKROOT = watchos; + SKIP_INSTALL = YES; + SUPPORTED_PLATFORMS = "watchos watchsimulator"; + SWIFT_EMIT_LOC_STRINGS = YES; + SWIFT_VERSION = 6.0; + TARGETED_DEVICE_FAMILY = 4; + }; + name = Release; + }; + F7A000000000000000000247 /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + GENERATE_INFOPLIST_FILE = YES; + PRODUCT_BUNDLE_IDENTIFIER = energy.ftw.app.watchkitapp.uitests; + PRODUCT_NAME = "$(TARGET_NAME)"; + SDKROOT = watchos; + SUPPORTED_PLATFORMS = "watchos watchsimulator"; + SWIFT_VERSION = 6.0; + TARGETED_DEVICE_FAMILY = 4; + TEST_TARGET_NAME = FTWWatch; + }; + name = Debug; + }; + F7A000000000000000000248 /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + GENERATE_INFOPLIST_FILE = YES; + PRODUCT_BUNDLE_IDENTIFIER = energy.ftw.app.watchkitapp.uitests; + PRODUCT_NAME = "$(TARGET_NAME)"; + SDKROOT = watchos; + SUPPORTED_PLATFORMS = "watchos watchsimulator"; + SWIFT_VERSION = 6.0; + TARGETED_DEVICE_FAMILY = 4; + TEST_TARGET_NAME = FTWWatch; + }; + name = Release; + }; /* End XCBuildConfiguration section */ /* Begin XCConfigurationList section */ @@ -738,6 +1119,33 @@ defaultConfigurationIsVisible = 0; defaultConfigurationName = Release; }; + F7A000000000000000000226 /* Build configuration list for PBXNativeTarget "FTWWatch" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + F7A000000000000000000227 /* Debug */, + F7A000000000000000000228 /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; + F7A000000000000000000236 /* Build configuration list for PBXNativeTarget "FTWWatchWidgets" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + F7A000000000000000000237 /* Debug */, + F7A000000000000000000238 /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; + F7A000000000000000000246 /* Build configuration list for PBXNativeTarget "FTWWatchUITests" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + F7A000000000000000000247 /* Debug */, + F7A000000000000000000248 /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; /* End XCConfigurationList section */ /* Begin XCLocalSwiftPackageReference section */ @@ -768,6 +1176,16 @@ package = F7A000000000000000000015 /* XCLocalSwiftPackageReference "FTWKit" */; productName = FTWShared; }; + F7A000000000000000000254 /* FTWShared */ = { + isa = XCSwiftPackageProductDependency; + package = F7A000000000000000000015 /* XCLocalSwiftPackageReference "FTWKit" */; + productName = FTWShared; + }; + F7A000000000000000000256 /* FTWShared */ = { + isa = XCSwiftPackageProductDependency; + package = F7A000000000000000000015 /* XCLocalSwiftPackageReference "FTWKit" */; + productName = FTWShared; + }; /* End XCSwiftPackageProductDependency section */ }; rootObject = F7A000000000000000000001 /* Project object */; diff --git a/appleApp/FTW.xcodeproj/xcshareddata/xcschemes/FTWWatch.xcscheme b/appleApp/FTW.xcodeproj/xcshareddata/xcschemes/FTWWatch.xcscheme new file mode 100644 index 0000000..b3ca531 --- /dev/null +++ b/appleApp/FTW.xcodeproj/xcshareddata/xcschemes/FTWWatch.xcscheme @@ -0,0 +1,89 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/appleApp/FTW/FTWApp.swift b/appleApp/FTW/FTWApp.swift index 9d65ab4..d9b4492 100644 --- a/appleApp/FTW/FTWApp.swift +++ b/appleApp/FTW/FTWApp.swift @@ -23,7 +23,11 @@ struct FTWApp: App { // 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() }, + // The watch gets the same records the widgets just got. + sharedWritten: { + WidgetCenter.shared.reloadAllTimelines() + WatchBridge.shared.cacheWritten() + }, passkeys: Passkeys(), build: AppInfo.build, ua: AppInfo.userAgent, @@ -33,6 +37,9 @@ struct FTWApp: App { // Siri, the Control and the Live Activity reach the box through the // app, and Charge now starts its Live Activity wherever it is pressed. IntentRunner.install(model) + // The Apple Watch reads through this phone and sends its commands + // here, over the same runner. + if let runner = IntentRunner.shared { WatchBridge.shared.install(model, runner: runner) } // A paired phone paints its cached home before anything else runs. model.launch() #if DEBUG diff --git a/appleApp/FTW/Intents/IntentRunner.swift b/appleApp/FTW/Intents/IntentRunner.swift index 37e0d84..ba39abc 100644 --- a/appleApp/FTW/Intents/IntentRunner.swift +++ b/appleApp/FTW/Intents/IntentRunner.swift @@ -119,6 +119,15 @@ final class IntentRunner: CommandRunner { return (app.site?.siteId ?? app.sites.current()?.siteId) == siteId } + /// The home with a session on its way, for as long as `body` runs. The + /// Apple Watch's commands and its look at Now count here with the + /// intents, so the last of them to finish closes the session. + func hold(_ body: @MainActor (HomeModels) async -> T) async -> T? { + guard let home = await begin() else { return nil } + defer { end() } + return await body(home) + } + /// The home, with a session on its way. A launch for an intent has /// already started connecting; an app whose session was closed in the /// background opens a new one. diff --git a/appleApp/FTW/Watch/WatchBridge.swift b/appleApp/FTW/Watch/WatchBridge.swift new file mode 100644 index 0000000..2c8aa6a --- /dev/null +++ b/appleApp/FTW/Watch/WatchBridge.swift @@ -0,0 +1,321 @@ +import FTWKit +import FTWShared +import Observation +import os +import UIKit +import WatchConnectivity + +/// The iPhone's side of the Apple Watch. +/// +/// watchOS gives an ordinary app no WebSocket and no Network connection +/// (TN3135), so the watch can neither reach the relay nor hold a key. This +/// phone does both for it: +/// +/// - After each write of the widgets' cache it hands the watch the same +/// records (`updateApplicationContext`). When tomorrow's prices arrive it +/// also wakes the watch's complication (`transferCurrentComplicationUserInfo`). +/// - While the watch app is on screen, it opens its own session and sends +/// Now's readings as they arrive, and stops when the watch looks away. +/// - A command from the watch (`sendMessageData`) runs here, over this +/// phone's session, exactly as the car view would send it. Each answer +/// from the box goes back as a receipt. FTW Cloud never controls. +/// +/// A watch unlocked on the wrist counts as unlocked for its commands +/// (Fredrik, 14 Oct 2026). The device key opens after the first unlock, so +/// a locked phone in a pocket can still carry them. +@MainActor +final class WatchBridge: NSObject { + static let shared = WatchBridge() + nonisolated static let log = Logger(subsystem: "energy.ftw.app", category: "watch") + + private var app: AppModel? + private var runner: IntentRunner? + private var session: WCSession? { WCSession.isSupported() ? WCSession.default : nil } + + /// The context last handed over, so the same records are not sent twice. + private var lastSent: WatchContext? + /// The last command's end, kept in the context for a watch that slept. + private var lastReceipt: WatchReceipt? + /// Writes come in bursts (prices, plan, car): one send after the last. + private var pending: Task? + /// The demo writes no cache. While it runs, its records are watched + /// instead, and sent at most this often. + private static let demoEveryMs: Double = 60_000 + private var demoSentAtMs: Double = 0 + /// While the watch app is on screen: the session held open and Now's + /// readings followed. + private var look: Task? + private var lookTimer: Task? + /// The watch says it still looks each time it comes to the front. Past + /// this without word, the session is let go. + private static let lookMs: Double = 120_000 + + private var nowMs: Double { Date.now.timeIntervalSince1970 * 1000 } + + /// Once, at launch, after the intents' runner. + func install(_ app: AppModel, runner: IntentRunner) { + self.app = app + self.runner = runner + guard let session else { return } + session.delegate = self + session.activate() + followHome() + } + + /// After every write to the App Group: the watch's copy follows. + func cacheWritten() { + schedule(after: .milliseconds(500)) + } + + private func schedule(after delay: Duration) { + pending?.cancel() + pending = Task { [weak self] in + try? await Task.sleep(for: delay) + guard !Task.isCancelled else { return } + self?.send() + } + } + + /// Hand the watch what the phone holds now. Only to a paired watch with + /// FTW installed; WatchConnectivity keeps the newest and delivers it + /// when the watch next wakes. + private func send() { + guard let session, session.activationState == .activated, let app else { return } + if app.isDemo { demoSentAtMs = nowMs } + guard session.isPaired, session.isWatchAppInstalled else { + Self.log.info("no watch to hand the context to: paired \(session.isPaired, privacy: .public), FTW on it \(session.isWatchAppInstalled, privacy: .public)") + return + } + let ctx = WatchShare.context(app.home, isDemo: app.isDemo, receipt: lastReceipt, nowMs: nowMs) + if let lastSent, lastSent.sameAs(ctx) { return } + do { + let data = try ctx.packed() + try session.updateApplicationContext([WatchContext.key: data]) + if PriceEntry.bringsNewDay(old: lastSent?.prices?.value, new: ctx.prices?.value, nowMs: nowMs), + session.isComplicationEnabled, session.remainingComplicationUserInfoTransfers > 0 { + session.transferCurrentComplicationUserInfo([WatchContext.key: data]) + Self.log.info("tomorrow's prices to the complication, \(session.remainingComplicationUserInfoTransfers, privacy: .public) transfers left today") + } + lastSent = ctx + Self.log.info("context to the watch: \(data.count, privacy: .public) bytes, home \(ctx.hasHome ? "yes" : "none", privacy: .public)") + } catch { + Self.log.error("context to the watch failed: \(error.localizedDescription, privacy: .public)") + } + } + + /// The demo leaves no cache to follow: its records are watched + /// directly. A paired home writes its cache, and that write sends. + private func followHome() { + guard let app else { return } + withObservationTracking { + _ = app.home + guard app.isDemo, let home = app.home else { return } + _ = home.prices.window + _ = home.loadpoints.stamp + _ = home.nodes.plan.stamped + } onChange: { + Task { @MainActor [weak self] in + guard let self else { return } + self.followHome() + // The demo's own changes go at most once a minute; the last + // of a burst waits for the minute to end. + let wait = self.app?.isDemo == true ? max(500, Self.demoEveryMs - (self.nowMs - self.demoSentAtMs)) : 500 + self.schedule(after: .milliseconds(Int(wait))) + } + } + } + + // MARK: The watch is looking + + private func startLooking() { + send() + // Asked again while it looks: keep the session, and only the clock + // that lets it go starts over. + if look == nil, let runner { + look = Task { [weak self] in + let time = BackgroundTime("FTW watch looks") { self?.stopLooking() } + defer { time.end() } + _ = await runner.hold { home in + await self?.forwardNow(home) + } + } + } + lookTimer?.cancel() + lookTimer = Task { [weak self] in + try? await Task.sleep(for: .milliseconds(Self.lookMs)) + guard !Task.isCancelled else { return } + self?.stopLooking() + } + } + + private func stopLooking() { + look?.cancel() + look = nil + lookTimer?.cancel() + lookTimer = nil + } + + /// Now's readings to the watch as each frame lands, until the watch + /// looks away, stops being reachable, or says nothing for `lookMs`. + private func forwardNow(_ home: HomeModels) async { + Self.log.info("watch looks: sending Now") + let frames = FrameWatch(site: home.site) + // The watch's pages count as screens that show these. + let done = WatchShare.looking(home) + defer { + frames.stop() + done() + } + var lastAt: Double? + send(news: WatchShare.now(home).map(WatchNews.now)) + // Ends when the look is let go; a watch out of reach meanwhile gets + // nothing, and its reachability ends the look. + for await _ in frames.stream { + guard session?.isReachable == true else { continue } + guard let record = WatchShare.now(home), record.readAtMs != lastAt else { continue } + lastAt = record.readAtMs + send(news: .now(record)) + } + Self.log.info("watch looked away: Now no longer sent") + } + + private func send(news: WatchNews?) { + guard let news, let session, session.isReachable, let data = try? news.data() else { return } + // WatchConnectivity calls back on its own queue, never the main one. + session.sendMessageData(data, replyHandler: nil) { @Sendable error in + WatchBridge.log.notice("news to the watch not delivered: \(error.localizedDescription, privacy: .public)") + } + } + + // MARK: A command from the watch + + private func run(_ command: WatchCommand) async { + let time = BackgroundTime("FTW watch command") + defer { time.end() } + let started = ContinuousClock.now + let end = await runner?.hold { [weak self] home in + await WatchShare.run(command, home: home, nowMs: Date.now.timeIntervalSince1970 * 1000) { step in + self?.send(news: .receipt(step)) + } + } + let receipt = end ?? WatchReceipt(commandID: command.id, op: command.op, sent: .failed, accepted: .waiting, confirmed: .waiting, text: ChargeNowActivity.openAndRetry, tone: .failed) + Self.log.info("watch command \(String(describing: command.op), privacy: .public): \(receipt.tone.rawValue, privacy: .public) in \((ContinuousClock.now - started) / .milliseconds(1), format: .fixed(precision: 0), privacy: .public) ms") + lastReceipt = receipt + send(news: .receipt(receipt)) + // The car or the mode moved: the context carries the end and the + // new state for a watch that looks later. + await app?.site?.flushShared() + send() + } + + private func received(_ ask: WatchAsk) { + switch ask { + case .look: startLooking() + case .away: stopLooking() + case .command(let c): Task { await run(c) } + } + } +} + +extension WatchBridge: WCSessionDelegate { + nonisolated func session(_ session: WCSession, activationDidCompleteWith activationState: WCSessionActivationState, error: Error?) { + if let error { WatchBridge.log.error("watch session: \(error.localizedDescription, privacy: .public)") } + Task { @MainActor in self.send() } + } + + nonisolated func sessionDidBecomeInactive(_ session: WCSession) {} + + /// The person switched to another watch: the new one gets its own + /// context. + nonisolated func sessionDidDeactivate(_ session: WCSession) { + WCSession.default.activate() + Task { @MainActor in self.lastSent = nil } + } + + /// FTW was installed on the watch, or a complication was added. + nonisolated func sessionWatchStateDidChange(_ session: WCSession) { + Task { @MainActor in + self.lastSent = nil + self.send() + } + } + + nonisolated func sessionReachabilityDidChange(_ session: WCSession) { + let reachable = session.isReachable + Task { @MainActor in if !reachable { self.stopLooking() } } + } + + nonisolated func session(_ session: WCSession, didReceiveMessageData messageData: Data) { + guard let ask = WatchAsk.from(messageData) else { return } + Task { @MainActor in self.received(ask) } + } + + /// A command: the phone says at once that it has it, then the box's + /// answers follow as news. + nonisolated func session(_ session: WCSession, didReceiveMessageData messageData: Data, replyHandler: @escaping (Data) -> Void) { + guard let ask = WatchAsk.from(messageData) else { return replyHandler(Data()) } + if case .command(let c) = ask, let data = try? WatchNews.receipt(.phoneHasIt(c)).data() { + replyHandler(data) + } else { + replyHandler(Data()) + } + Task { @MainActor in self.received(ask) } + } +} + +/// Each change of the reading on screen, as a stream, for as long as it is +/// wanted. Observation drives it; nothing polls. +@MainActor +private final class FrameWatch { + let stream: AsyncStream + private let continuation: AsyncStream.Continuation + private let site: SiteModel + private var stopped = false + + init(site: SiteModel) { + (stream, continuation) = AsyncStream.makeStream(bufferingPolicy: .bufferingNewest(1)) + self.site = site + arm() + } + + func stop() { + stopped = true + continuation.finish() + } + + private func arm() { + guard !stopped else { return } + withObservationTracking { + _ = site.readingAtMs + _ = site.carrier + } onChange: { [weak self] in + Task { @MainActor in + self?.continuation.yield() + self?.arm() + } + } + } +} + +/// A little time to finish in the background. The watch wakes this app +/// for a command or a look; without it iOS may freeze the app before the +/// box answers. UIKit is the only door to it. +@MainActor +private final class BackgroundTime { + private var id: UIBackgroundTaskIdentifier = .invalid + + init(_ name: String, expired: @escaping @MainActor () -> Void = {}) { + id = UIApplication.shared.beginBackgroundTask(withName: name) { [weak self] in + MainActor.assumeIsolated { + expired() + self?.end() + } + } + } + + func end() { + guard id != .invalid else { return } + UIApplication.shared.endBackgroundTask(id) + id = .invalid + } +} diff --git a/appleApp/FTWCharts/Theme.xcassets/ftw.alarm.colorset/Contents.json b/appleApp/FTWCharts/Theme.xcassets/ftw.alarm.colorset/Contents.json index 8d2079d..52a7411 100644 --- a/appleApp/FTWCharts/Theme.xcassets/ftw.alarm.colorset/Contents.json +++ b/appleApp/FTWCharts/Theme.xcassets/ftw.alarm.colorset/Contents.json @@ -29,6 +29,18 @@ } }, "idiom": "universal" + }, + { + "color": { + "color-space": "display-p3", + "components": { + "alpha": "1.000", + "red": "0xFF", + "green": "0x45", + "blue": "0x3A" + } + }, + "idiom": "watch" } ], "info": { diff --git a/appleApp/FTWCharts/Theme.xcassets/ftw.car.colorset/Contents.json b/appleApp/FTWCharts/Theme.xcassets/ftw.car.colorset/Contents.json index c368099..ac9fad1 100644 --- a/appleApp/FTWCharts/Theme.xcassets/ftw.car.colorset/Contents.json +++ b/appleApp/FTWCharts/Theme.xcassets/ftw.car.colorset/Contents.json @@ -29,6 +29,18 @@ } }, "idiom": "universal" + }, + { + "color": { + "color-space": "display-p3", + "components": { + "alpha": "1.000", + "red": "0xB6", + "green": "0x9C", + "blue": "0xFF" + } + }, + "idiom": "watch" } ], "info": { diff --git a/appleApp/FTWCharts/Theme.xcassets/ftw.gives.colorset/Contents.json b/appleApp/FTWCharts/Theme.xcassets/ftw.gives.colorset/Contents.json index d6989d9..8b60250 100644 --- a/appleApp/FTWCharts/Theme.xcassets/ftw.gives.colorset/Contents.json +++ b/appleApp/FTWCharts/Theme.xcassets/ftw.gives.colorset/Contents.json @@ -29,6 +29,18 @@ } }, "idiom": "universal" + }, + { + "color": { + "color-space": "display-p3", + "components": { + "alpha": "1.000", + "red": "0xFF", + "green": "0xB0", + "blue": "0x00" + } + }, + "idiom": "watch" } ], "info": { diff --git a/appleApp/FTWCharts/Theme.xcassets/ftw.grid.colorset/Contents.json b/appleApp/FTWCharts/Theme.xcassets/ftw.grid.colorset/Contents.json index 6ba5fd0..b556188 100644 --- a/appleApp/FTWCharts/Theme.xcassets/ftw.grid.colorset/Contents.json +++ b/appleApp/FTWCharts/Theme.xcassets/ftw.grid.colorset/Contents.json @@ -29,6 +29,18 @@ } }, "idiom": "universal" + }, + { + "color": { + "color-space": "display-p3", + "components": { + "alpha": "1.000", + "red": "0xFF", + "green": "0x5F", + "blue": "0xA8" + } + }, + "idiom": "watch" } ], "info": { diff --git a/appleApp/FTWCharts/Theme.xcassets/ftw.ground.colorset/Contents.json b/appleApp/FTWCharts/Theme.xcassets/ftw.ground.colorset/Contents.json index 7371bbd..994f8a7 100644 --- a/appleApp/FTWCharts/Theme.xcassets/ftw.ground.colorset/Contents.json +++ b/appleApp/FTWCharts/Theme.xcassets/ftw.ground.colorset/Contents.json @@ -29,6 +29,18 @@ } }, "idiom": "universal" + }, + { + "color": { + "color-space": "display-p3", + "components": { + "alpha": "1.000", + "red": "0x05", + "green": "0x08", + "blue": "0x0F" + } + }, + "idiom": "watch" } ], "info": { diff --git a/appleApp/FTWCharts/Theme.xcassets/ftw.hairline.colorset/Contents.json b/appleApp/FTWCharts/Theme.xcassets/ftw.hairline.colorset/Contents.json index b2d90f5..2594109 100644 --- a/appleApp/FTWCharts/Theme.xcassets/ftw.hairline.colorset/Contents.json +++ b/appleApp/FTWCharts/Theme.xcassets/ftw.hairline.colorset/Contents.json @@ -29,6 +29,18 @@ } }, "idiom": "universal" + }, + { + "color": { + "color-space": "display-p3", + "components": { + "alpha": "1.000", + "red": "0x1B", + "green": "0x24", + "blue": "0x33" + } + }, + "idiom": "watch" } ], "info": { diff --git a/appleApp/FTWCharts/Theme.xcassets/ftw.ink.colorset/Contents.json b/appleApp/FTWCharts/Theme.xcassets/ftw.ink.colorset/Contents.json index 4135819..947c591 100644 --- a/appleApp/FTWCharts/Theme.xcassets/ftw.ink.colorset/Contents.json +++ b/appleApp/FTWCharts/Theme.xcassets/ftw.ink.colorset/Contents.json @@ -29,6 +29,18 @@ } }, "idiom": "universal" + }, + { + "color": { + "color-space": "display-p3", + "components": { + "alpha": "1.000", + "red": "0xEE", + "green": "0xF3", + "blue": "0xFF" + } + }, + "idiom": "watch" } ], "info": { diff --git a/appleApp/FTWCharts/Theme.xcassets/ftw.ink2.colorset/Contents.json b/appleApp/FTWCharts/Theme.xcassets/ftw.ink2.colorset/Contents.json index ec04e7b..9e1c8e9 100644 --- a/appleApp/FTWCharts/Theme.xcassets/ftw.ink2.colorset/Contents.json +++ b/appleApp/FTWCharts/Theme.xcassets/ftw.ink2.colorset/Contents.json @@ -29,6 +29,18 @@ } }, "idiom": "universal" + }, + { + "color": { + "color-space": "display-p3", + "components": { + "alpha": "1.000", + "red": "0x9A", + "green": "0xA5", + "blue": "0xB8" + } + }, + "idiom": "watch" } ], "info": { diff --git a/appleApp/FTWCharts/Theme.xcassets/ftw.ink3.colorset/Contents.json b/appleApp/FTWCharts/Theme.xcassets/ftw.ink3.colorset/Contents.json index a6e0d8e..4f284d6 100644 --- a/appleApp/FTWCharts/Theme.xcassets/ftw.ink3.colorset/Contents.json +++ b/appleApp/FTWCharts/Theme.xcassets/ftw.ink3.colorset/Contents.json @@ -69,6 +69,18 @@ } }, "idiom": "universal" + }, + { + "color": { + "color-space": "display-p3", + "components": { + "alpha": "1.000", + "red": "0x72", + "green": "0x7D", + "blue": "0x90" + } + }, + "idiom": "watch" } ], "info": { diff --git a/appleApp/FTWCharts/Theme.xcassets/ftw.live.colorset/Contents.json b/appleApp/FTWCharts/Theme.xcassets/ftw.live.colorset/Contents.json index 72e970d..a8abc13 100644 --- a/appleApp/FTWCharts/Theme.xcassets/ftw.live.colorset/Contents.json +++ b/appleApp/FTWCharts/Theme.xcassets/ftw.live.colorset/Contents.json @@ -29,6 +29,18 @@ } }, "idiom": "universal" + }, + { + "color": { + "color-space": "display-p3", + "components": { + "alpha": "1.000", + "red": "0x83", + "green": "0xF5", + "blue": "0x2C" + } + }, + "idiom": "watch" } ], "info": { diff --git a/appleApp/FTWCharts/Theme.xcassets/ftw.raised.colorset/Contents.json b/appleApp/FTWCharts/Theme.xcassets/ftw.raised.colorset/Contents.json index 700bcd9..07d3c28 100644 --- a/appleApp/FTWCharts/Theme.xcassets/ftw.raised.colorset/Contents.json +++ b/appleApp/FTWCharts/Theme.xcassets/ftw.raised.colorset/Contents.json @@ -29,6 +29,18 @@ } }, "idiom": "universal" + }, + { + "color": { + "color-space": "display-p3", + "components": { + "alpha": "1.000", + "red": "0x0B", + "green": "0x11", + "blue": "0x1C" + } + }, + "idiom": "watch" } ], "info": { diff --git a/appleApp/FTWCharts/Theme.xcassets/ftw.solar.colorset/Contents.json b/appleApp/FTWCharts/Theme.xcassets/ftw.solar.colorset/Contents.json index a305759..dbdb8d4 100644 --- a/appleApp/FTWCharts/Theme.xcassets/ftw.solar.colorset/Contents.json +++ b/appleApp/FTWCharts/Theme.xcassets/ftw.solar.colorset/Contents.json @@ -29,6 +29,18 @@ } }, "idiom": "universal" + }, + { + "color": { + "color-space": "display-p3", + "components": { + "alpha": "1.000", + "red": "0xFF", + "green": "0xE1", + "blue": "0x4D" + } + }, + "idiom": "watch" } ], "info": { diff --git a/appleApp/FTWCharts/Theme.xcassets/ftw.stores.colorset/Contents.json b/appleApp/FTWCharts/Theme.xcassets/ftw.stores.colorset/Contents.json index 6ef68a9..f1252a1 100644 --- a/appleApp/FTWCharts/Theme.xcassets/ftw.stores.colorset/Contents.json +++ b/appleApp/FTWCharts/Theme.xcassets/ftw.stores.colorset/Contents.json @@ -29,6 +29,18 @@ } }, "idiom": "universal" + }, + { + "color": { + "color-space": "display-p3", + "components": { + "alpha": "1.000", + "red": "0x18", + "green": "0xD9", + "blue": "0xFF" + } + }, + "idiom": "watch" } ], "info": { diff --git a/appleApp/FTWKit/Package.swift b/appleApp/FTWKit/Package.swift index 2246ba1..95b1d90 100644 --- a/appleApp/FTWKit/Package.swift +++ b/appleApp/FTWKit/Package.swift @@ -19,7 +19,7 @@ import PackageDescription let package = Package( name: "FTWKit", defaultLocalization: "en", - platforms: [.iOS(.v26), .macOS(.v26)], + platforms: [.iOS(.v26), .macOS(.v26), .watchOS(.v26)], products: [ .library(name: "FTWKit", targets: ["FTWKit"]), .library(name: "FTWShared", targets: ["FTWShared"]), diff --git a/appleApp/FTWKit/Sources/FTWKit/Protocol/Session.swift b/appleApp/FTWKit/Sources/FTWKit/Protocol/Session.swift index c0ff575..3f2a364 100644 --- a/appleApp/FTWKit/Sources/FTWKit/Protocol/Session.swift +++ b/appleApp/FTWKit/Sources/FTWKit/Protocol/Session.swift @@ -396,10 +396,12 @@ public final class Session { /// 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. `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 { + /// so a screen can say so before the result. `cmdId` is given when the + /// intent was made elsewhere (the Apple Watch), so the box runs it once + /// whichever way it arrives. + public func command(_ op: String, args: [(String, CBOR)], guards: [Guard] = [], onAck: (@MainActor () -> Void)? = nil, cmdId given: String? = nil) async throws -> CmdResult { guard let carrier else { throw SessionError.noCarrier } - let cmdId = Self.uuidv7(nowMs: scheduler.nowMs) + let cmdId = given ?? Self.uuidv7(nowMs: scheduler.nowMs) let body = CBOR.map([ ("cmdId", .text(cmdId)), ("op", .text(op)), diff --git a/appleApp/FTWKit/Sources/FTWKit/State/LoadpointsModel.swift b/appleApp/FTWKit/Sources/FTWKit/State/LoadpointsModel.swift index d9ca80b..71e7744 100644 --- a/appleApp/FTWKit/Sources/FTWKit/State/LoadpointsModel.swift +++ b/appleApp/FTWKit/Sources/FTWKit/State/LoadpointsModel.swift @@ -409,22 +409,22 @@ public final class LoadpointsModel: Activatable { /// 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 { + public func chargeNow(_ lp: Loadpoint, cmdId: String? = nil) 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"))]) + 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"))], cmdId: cmdId) } /// 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))]) + public func pause(_ lp: Loadpoint, cmdId: String? = nil) async { + await hold(lp, .pause, [("id", .text(lp.id)), ("power_w", .int(0)), ("hold_s", .int(0))], cmdId: cmdId) } /// 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 backToPlan(_ lp: Loadpoint, cmdId: String? = nil) async { + await hold(lp, .resume, [("id", .text(lp.id)), ("clear", .bool(true))], cmdId: cmdId) } - private func hold(_ lp: Loadpoint, _ ask: EVText.Step.Ask, _ args: [(String, CBOR)]) async { + private func hold(_ lp: Loadpoint, _ ask: EVText.Step.Ask, _ args: [(String, CBOR)], cmdId: String? = nil) async { if let step = holdStep[lp.id], !step.settled { return } // Charge now shows outside the app as the receipt does inside it: // sent at once, received on the box's `cmd.ack`, on only once the @@ -438,7 +438,7 @@ public final class LoadpointsModel: Activatable { self.showChargeNow(id, .received(atMs: self.site.scheduler.nowMs)) } } - await follow(lp.id, ask, op: Contract.opLoadpointHold, args: args, into: \.holdStep, tag: "hold", acked: acked) + await follow(lp.id, ask, op: Contract.opLoadpointHold, args: args, into: \.holdStep, tag: "hold", acked: acked, cmdId: cmdId) // The box's answer moves the Live Activity at once: the read below // can take a while. let result = holdStep[lp.id]?.ask == ask ? holdStep[lp.id]?.result : nil @@ -501,7 +501,7 @@ public final class LoadpointsModel: Activatable { /// 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, acked: (@MainActor () -> Void)? = nil) async { + private func follow(_ id: String, _ ask: EVText.Step.Ask, op: String, args: [(String, CBOR)], into path: ReferenceWritableKeyPath, tag: String, acked: (@MainActor () -> Void)? = nil, cmdId: String? = nil) async { let key = "\(tag) \(id)" clearing[key]?.cancel() self[keyPath: path][id] = EVText.Step(ask: ask) @@ -511,7 +511,7 @@ public final class LoadpointsModel: Activatable { guard let self, self[keyPath: path][id]?.ask == ask else { return } self[keyPath: path][id]?.acked = true acked?() - }) + }, cmdId: cmdId) switch r.state { case .applied: result = .applied(observed: r.observedValue) case .superseded: result = .superseded(observed: r.observedValue) @@ -632,7 +632,7 @@ public final class LoadpointsModel: Activatable { /// says how it ended. The Live Activity moves as it does for a tap in /// the car view. A notification names its charger in `loadpointID`; /// the others let the box's account pick one. - public func perform(_ command: RemoteCommand, loadpointID: String? = nil, waitMs: Double = LoadpointsModel.remoteStreamWaitMs) async -> RemoteOutcome { + public func perform(_ command: RemoteCommand, loadpointID: String? = nil, waitMs: Double = LoadpointsModel.remoteStreamWaitMs, cmdId: String? = nil) async -> RemoteOutcome { // A Live Activity's button names its home. One from another home, // or from the demo, never reaches this one: its card closes instead. if case .backToPlan(let id, let home?) = command, home != activityHome { @@ -670,7 +670,7 @@ public final class LoadpointsModel: Activatable { case .chargeNow, .backToPlan: guard !holdSending(lp) else { return RemoteOutcome(.failed, busy) } let ask: EVText.Step.Ask = command == .chargeNow ? .chargeNow : .resume - if ask == .chargeNow { await chargeNow(lp) } else { await backToPlan(lp) } + if ask == .chargeNow { await chargeNow(lp, cmdId: cmdId) } else { await backToPlan(lp, cmdId: cmdId) } return remoteOutcome(holdStep[lp.id], lp.id, ask: ask, busy: busy) case .setLevel(let pct): guard !levelSending.contains(lp.id) else { return RemoteOutcome(.failed, busy) } diff --git a/appleApp/FTWKit/Sources/FTWKit/State/ModeModel.swift b/appleApp/FTWKit/Sources/FTWKit/State/ModeModel.swift index 699f9e6..ee31d8f 100644 --- a/appleApp/FTWKit/Sources/FTWKit/State/ModeModel.swift +++ b/appleApp/FTWKit/Sources/FTWKit/State/ModeModel.swift @@ -245,9 +245,9 @@ public final class ModeModel { // MARK: Changing it /// Run a mode. The tap shows at once; the box decides. - public func set(_ mode: String) async { + public func set(_ mode: String, cmdId: String? = nil) async { guard mode != shown else { return } - await send(.mode(mode), op: Contract.opSetMode, args: [("mode", .text(mode))]) + await send(.mode(mode), op: Contract.opSetMode, args: [("mode", .text(mode))], cmdId: cmdId) } /// Back from a manual mode to the plan the household chose. @@ -268,7 +268,7 @@ public final class ModeModel { /// How long a tap waits for the stream before it gives up. nonisolated static let streamWaitMs: Double = 3_000 - private func send(_ ask: Ask, op: String, args: [(String, CBOR)]) async { + private func send(_ ask: Ask, op: String, args: [(String, CBOR)], cmdId: String? = nil) async { guard !sending else { return } clearing?.cancel() change = Change(ask: ask) @@ -287,7 +287,7 @@ public final class ModeModel { let r = try await site.core.command(op, args: args, onAck: { [weak self] in guard let self, self.change?.ask == ask else { return } self.change?.acked = true - }) + }, cmdId: cmdId) switch r.state { case .applied: result = .applied case .superseded: result = .superseded diff --git a/appleApp/FTWKit/Sources/FTWKit/State/WatchShare.swift b/appleApp/FTWKit/Sources/FTWKit/State/WatchShare.swift new file mode 100644 index 0000000..b1b3258 --- /dev/null +++ b/appleApp/FTWKit/Sources/FTWKit/State/WatchShare.swift @@ -0,0 +1,330 @@ +import Foundation +import FTWShared +import Observation + +/// What the phone hands its Apple Watch, and how it runs what the watch +/// asks for. The watch reaches the box only through this phone: it can +/// open no relay and holds no key. +/// +/// The records are the ones the widgets read, built from the same models +/// with the same dating: each says where it came from and when, so a watch +/// can show its age and never call it live. The demo builds them too, so +/// the watch shows the simulated home while the phone does. +@MainActor +public enum WatchShare { + // MARK: What the watch shows + + public static func context(_ home: HomeModels?, isDemo: Bool, receipt: WatchReceipt? = nil, nowMs: Double) -> WatchContext { + guard let home else { return .empty(sentAtMs: nowMs) } + let key = homeKey(home) + return WatchContext( + home: key, + isDemo: isDemo, + prices: prices(home, key: key), + cars: cars(home, key: key, nowMs: nowMs), + plan: plan(home, key: key, nowMs: nowMs), + now: now(home, key: key), + modes: modes(home), + receipt: receipt, + sentAtMs: nowMs + ) + } + + /// The models the watch's pages show, read as if a screen showing them + /// had appeared: the prices, the chargers, the plan and the mode. Call + /// the returned closure when the watch looks away. + public static func looking(_ home: HomeModels) -> @MainActor () -> Void { + let (prices, chargers, plan, mode) = (home.prices, home.loadpoints, home.nodes.plan, home.nodes.mode) + prices.activate() + chargers.activate() + plan.activate() + mode.activate() + return { + prices.deactivate() + chargers.deactivate() + plan.deactivate() + mode.deactivate() + } + } + + /// The site, or the demo's own key, as a command from the watch names + /// it. + public static func homeKey(_ home: HomeModels) -> String { home.site.siteId ?? "demo" } + + static func carrier(_ origin: StampOrigin, site: SiteModel) -> CarrierKind { + switch origin { + case .cache: return .cache + case .cloud: return .cloud + case .relay: return site.carrier == .webrtc ? .webrtc : .relay + } + } + + static func prices(_ home: HomeModels, key: String) -> CacheRecord? { + guard let w = home.prices.window else { return nil } + return CacheRecord(siteId: key, carrier: carrier(w.origin, site: home.site), srcState: w.value.prices.stale ? .stale : .live, readAtMs: w.asOfMs, value: w.value) + } + + static func cars(_ home: HomeModels, key: String, nowMs: Double) -> CacheRecord? { + let lps = home.loadpoints + guard let stamp = lps.stamp else { return nil } + let entry = CarEntry(cars: stamp.value.map { lp in + CarEntry.Car( + loadpointID: lp.id, + pluggedIn: lp.pluggedIn, + socPct: lps.level(lp)?.pct ?? lp.socPct, + socSource: lp.socSource, + socReadAtMs: lp.socReadAtMs(answeredAtMs: stamp.asOfMs), + powerW: lp.powerW, + targetSocPct: lp.targetSocPct, + holding: LoadpointsModel.holding(lp) + ) + }) + let answering = !lps.stale && stamp.value.allSatisfy { $0.charger?.available != false } + return CacheRecord(siteId: key, carrier: carrier(stamp.origin, site: home.site), srcState: answering ? .live : .stale, readAtMs: stamp.asOfMs, value: entry) + } + + static func plan(_ home: HomeModels, key: String, nowMs: Double) -> CacheRecord? { + let model = home.nodes.plan + guard let stamped = model.stamped else { return nil } + let entry = home.nodes.planShare.entry.trimmed(nowMs: nowMs) + let sent = stamped.fromCache ? stamped.asOfMs : (model.receivedAtMs ?? stamped.asOfMs) + return CacheRecord(siteId: key, carrier: carrier(stamped.origin, site: home.site), srcState: entry.boxStale ? .stale : .live, readAtMs: sent, value: entry) + } + + /// Now's figures as the phone holds them. Nil before anything was read. + public static func now(_ home: HomeModels, key: String? = nil) -> CacheRecord? { + let site = home.site + guard let at = site.readingAtMs ?? site.lastFrameAtMs ?? site.cachedAtMs else { return nil } + let readings = NowFlow.readings(site: site, loadpoints: home.loadpoints).map(reading) + let answer = InControl.answer(streaming: site.isLive, lastKnownMs: nil, dispatchBlockedBy: site.session.dispatchBlockedBy, sources: site.session.sources) + let control: WatchNow.Control + switch answer.tone { + case .ok: control = .inControl + case .warning: control = .notInControl + case .neutral, .stale: control = .unknown + } + return CacheRecord(siteId: key ?? homeKey(home), carrier: site.carrier, srcState: site.isLive ? .live : .stale, readAtMs: at, value: WatchNow(readings: readings, control: control)) + } + + static func reading(_ r: NodeReading) -> WatchNow.Reading { + let way: WatchNow.Way + switch r.node { + case _ where r.watts == nil: way = .noReading + case .solar: way = r.flowing ? .making : .resting + case .battery: way = !r.flowing ? .resting : (r.towardHome ? .giving : .charging) + case .home: way = .using + case .grid: way = !r.flowing ? .balanced : (r.towardHome ? .buying : .selling) + case .car: way = r.flowing ? .charging : .resting + } + let node: WatchNow.Node + switch r.node { + case .solar: node = .solar + case .battery: node = .battery + case .home: node = .home + case .grid: node = .grid + case .car: node = .car + } + return WatchNow.Reading(node: node, watts: r.watts, way: way, levelPct: r.levelPct, levelAssumed: r.levelAssumed, live: r.freshness.live) + } + + static func modes(_ home: HomeModels) -> WatchModes? { + let mode = home.nodes.mode + guard !mode.modes.isEmpty else { return nil } + return WatchModes( + modes: mode.modes.map { WatchModes.Mode(key: $0.key, name: ModeWords.name($0), isPlan: ModeWords.isPlan($0.key)) }, + shown: mode.actual, + label: mode.label, + mayControl: mode.mayControl + ) + } + + // MARK: Commands + + /// How long a command from the watch waits for the session to stream. + /// The app may have been woken for it with no session open. + public static let streamWaitMs: Double = LoadpointsModel.remoteStreamWaitMs + + /// Run one command from the watch on `home`, telling `step` each time + /// the box answers. Returns the last receipt. Every mark comes from the + /// phone's own receipt for the command, so CONFIRMED means the box's + /// `cmd.result` said applied. + public static func run(_ c: WatchCommand, home: HomeModels, nowMs: Double, step: @escaping @MainActor (WatchReceipt) -> Void) async -> WatchReceipt { + func refused(_ text: String) -> WatchReceipt { + WatchReceipt(commandID: c.id, op: c.op, sent: .failed, accepted: .waiting, confirmed: .waiting, text: text, tone: .failed) + } + guard c.home == homeKey(home) else { return refused(WatchWord.otherHome.text) } + guard WatchCommand.isID(c.id) else { return refused(AppWords.text("That did not go through. Try again.")) } + guard nowMs <= c.notValidAfterMs else { return refused(WatchWord.tooLate.text) } + + let lps = home.loadpoints + switch c.op { + case .chargeNow(let id), .backToPlan(let id), .pause(let id): + // The car's receipt may still show the last command; this one's + // starts when the step changes. + let since = Since(lps.holdStep[id]) + let follower = Follower(read: { + guard let s = since.fresh(lps.holdStep[id]) else { return nil } + return receipt(EVText.outcome(s, lps.points.first { $0.id == id }), c) + }, step: step) + defer { follower.stop() } + let out: RemoteOutcome + switch c.op { + case .chargeNow: out = await lps.perform(.chargeNow, loadpointID: id, waitMs: streamWaitMs, cmdId: c.id) + case .backToPlan: out = await lps.perform(.backToPlan(loadpointID: id, home: nil), loadpointID: id, waitMs: streamWaitMs, cmdId: c.id) + default: out = await pause(id, lps, cmdId: c.id) + } + // The step on the car's receipt is this command's only if it + // was sent; a refusal before sending has none. + if let step = lps.holdStep[id], step.ask == ask(c.op), follower.saw { + return receipt(EVText.outcome(step, lps.points.first { $0.id == id }), c) + } + return out.kind == .failed ? refused(out.text) : WatchReceipt(commandID: c.id, op: c.op, sent: .done, accepted: .done, confirmed: out.kind == .done ? .done : .waiting, text: out.text, tone: out.kind == .done ? .done : .warning) + + case .mode(let key): + let mode = home.nodes.mode + guard mode.mayControl else { return refused(WatchWord.cannotChangeMode.text) } + guard await streaming(home.site, within: streamWaitMs) else { + return refused(AppWords.text("Not connected to FTW right now. Try again when the connection is back.")) + } + if mode.actual == key { + // The box already reports it: its own word, not a command's. + return WatchReceipt(commandID: c.id, op: c.op, sent: .waiting, accepted: .waiting, confirmed: .done, text: mode.label ?? ModeWords.name(mode.info(key)), tone: .done) + } + let since = Since(mode.change) + let follower = Follower(read: { + guard let change = since.fresh(mode.change), change.ask == .mode(key), let o = mode.outcome else { return nil } + return receipt(o, c) + }, step: step) + defer { follower.stop() } + await mode.set(key, cmdId: c.id) + guard let o = mode.outcome, mode.change?.ask == .mode(key) else { + return refused(AppWords.text("That did not go through. Try again.")) + } + return receipt(o, c) + } + } + + /// Pause, with the same checks Charge now gets on its way in: a stream, + /// a box that takes commands, and a charger that exists. + private static func pause(_ id: String, _ lps: LoadpointsModel, cmdId: String) async -> RemoteOutcome { + guard await lps.refreshForAnswer(waitMs: streamWaitMs) else { + return RemoteOutcome(.failed, AppWords.text("Not connected to FTW right now. Try again when the connection is back.")) + } + guard let lp = lps.points.first(where: { $0.id == id }) else { + return RemoteOutcome(.failed, LoadpointsModel.say("FTW has no charger to control.")) + } + guard !lps.holdSending(lp) else { + return RemoteOutcome(.failed, LoadpointsModel.say("FTW is still busy with your last command. Try again in a moment.")) + } + await lps.pause(lp, cmdId: cmdId) + return RemoteOutcome(.pending, "") + } + + static func ask(_ op: WatchCommand.Op) -> EVText.Step.Ask? { + switch op { + case .chargeNow: return .chargeNow + case .pause: return .pause + case .backToPlan: return .resume + case .mode: return nil + } + } + + /// The phone's receipt for a command, mark for mark. + static func receipt(_ o: EVText.Outcome, _ c: WatchCommand) -> WatchReceipt { + func mark(_ m: EVText.Outcome.Mark) -> WatchReceipt.Mark { + switch m { + case .done: return .done + case .active: return .active + case .waiting: return .waiting + case .failed: return .failed + } + } + let tone: WatchReceipt.Tone + switch o.tone { + case .working: tone = .working + case .done: tone = .done + case .warning: tone = .warning + case .failed: tone = .failed + } + return WatchReceipt(commandID: c.id, op: c.op, sent: mark(o.sent), accepted: mark(o.received), confirmed: mark(o.done), text: o.text, tone: tone) + } + + /// True once the session streams, false if it has not within `ms`. + static func streaming(_ site: SiteModel, within ms: Double) async -> Bool { + if site.session.phase == .streaming { return true } + let wait = StreamWait() + let ok = await withCheckedContinuation { cont in + wait.continuation = cont + wait.token = site.observe { [weak site, wait] in + if site?.session.phase == .streaming { wait.settle(true) } + } + wait.timer = site.scheduler.after(ms) { [wait] in wait.settle(false) } + } + if let token = wait.token { site.unobserve(token) } + return ok + } +} + +/// Passes each new receipt a model holds to `step`, from Observation, +/// until stopped. No timer: it runs when the model changes. +@MainActor +private final class Follower { + private let read: @MainActor () -> WatchReceipt? + private let step: @MainActor (WatchReceipt) -> Void + private var last: WatchReceipt? + private var stopped = false + /// Whether it saw a receipt of this command at all. + private(set) var saw = false + + init(read: @escaping @MainActor () -> WatchReceipt?, step: @escaping @MainActor (WatchReceipt) -> Void) { + self.read = read + self.step = step + arm() + } + + func stop() { stopped = true } + + private func arm() { + guard !stopped else { return } + let next = withObservationTracking { read() } onChange: { [weak self] in + Task { @MainActor in self?.arm() } + } + guard let next, next != last else { return } + last = next + saw = true + step(next) + } +} + +/// A value as it was when a command started. It counts as this command's +/// once it has changed, and from then on whatever it becomes. +@MainActor +private final class Since { + private let before: Value? + private var moved = false + + init(_ before: Value?) { + self.before = before + } + + func fresh(_ now: Value?) -> Value? { + if !moved { + guard now != before else { return nil } + moved = true + } + return now + } +} + +@MainActor +private final class StreamWait { + var continuation: CheckedContinuation? + var token: Int? + var timer: Cancellable? + + func settle(_ ok: Bool) { + timer?.cancel() + continuation?.resume(returning: ok) + continuation = nil + } +} diff --git a/appleApp/FTWKit/Sources/FTWShared/Resources/en.lproj/Watch.strings b/appleApp/FTWKit/Sources/FTWShared/Resources/en.lproj/Watch.strings new file mode 100644 index 0000000..a46b0fe --- /dev/null +++ b/appleApp/FTWKit/Sources/FTWShared/Resources/en.lproj/Watch.strings @@ -0,0 +1 @@ +/* The Apple Watch app and its complications. Keys are the English (WatchWord); English shows the key. */ diff --git a/appleApp/FTWKit/Sources/FTWShared/Resources/sv.lproj/Watch.strings b/appleApp/FTWKit/Sources/FTWShared/Resources/sv.lproj/Watch.strings new file mode 100644 index 0000000..42c850e --- /dev/null +++ b/appleApp/FTWKit/Sources/FTWShared/Resources/sv.lproj/Watch.strings @@ -0,0 +1,79 @@ +/* The Apple Watch app and its complications. Keys are the English (WatchWord). */ +"LIVE" = "LIVE"; +"DEMO" = "DEMO"; +"AS OF %@" = "PER %@"; +"NO READING YET" = "INGEN AVLÄSNING ÄN"; +"FTW IN CONTROL" = "FTW STYR"; +"▲ FTW NOT IN CONTROL" = "▲ FTW STYR INTE"; +"PLANNED %@" = "PLANERAD %@"; +"PLAN FROM %@" = "PLAN FRÅN %@"; + +"SENT" = "SKICKAT"; +"ACCEPTED" = "MOTTAGET"; +"CONFIRMED" = "BEKRÄFTAT"; +"Sending to your iPhone…" = "Skickar till din iPhone …"; +"Your iPhone is sending it to your box…" = "Din iPhone skickar det till boxen …"; +"Your iPhone can't be reached. Nothing was sent." = "Din iPhone nås inte. Inget skickades."; +"No answer from your iPhone yet. Check FTW there." = "Inget svar från din iPhone än. Titta i FTW där."; +"That came too late, so it was not sent. Try again." = "Det kom för sent och skickades inte. Försök igen."; +"That was for another home. Nothing was sent." = "Det gällde ett annat hem. Inget skickades."; +"This phone may not change how the home is run." = "Den här telefonen får inte ändra hur hemmet körs."; + +"iPhone not reachable" = "iPhone nås inte"; +"Open FTW on your iPhone" = "Öppna FTW på din iPhone"; +"The watch shows what your iPhone last read. Pair the phone with your box first." = "Klockan visar det din iPhone senast läste. Para ihop telefonen med boxen först."; + +"Solar" = "Sol"; +"Battery" = "Batteri"; +"Usage" = "Förbrukning"; +"Grid" = "Elnät"; +"Car" = "Bil"; +"making" = "producerar"; +"resting" = "vilar"; +"charging" = "laddar"; +"giving power" = "laddar ur"; +"using" = "använder"; +"buying" = "köper"; +"selling" = "säljer"; +"balanced" = "i balans"; +"no reading" = "ingen avläsning"; + +"Now" = "Nu"; +"Plan" = "Plan"; +"Price" = "Pris"; +"Mode" = "Läge"; + +"Battery stores" = "Batteriet laddar"; +"Battery gives power" = "Batteriet laddar ur"; +"Battery waits" = "Batteriet vilar"; +"No plan" = "Ingen plan"; +"car charges" = "bilen laddar"; +"car %@" = "bilen %@"; +"cheapest" = "billigast"; +"No price yet" = "Inget pris än"; +"Turn the Crown to step through the plan" = "Vrid på Digital Crown för att stega genom planen"; + +"Plugged in" = "Inkopplad"; +"Not plugged in" = "Inte inkopplad"; +"Charge now on" = "Ladda nu på"; +"Charging · %@" = "Laddar · %@"; +"Goal %@" = "Mål %@"; +"Charges %@" = "Laddar %@"; +"%@ assumed" = "%@ antaget"; +"FTW has no charger" = "FTW har ingen laddare"; +"Charge now" = "Ladda nu"; +"Pause" = "Pausa"; +"Back to the plan" = "Tillbaka till planen"; +"Start charging at once, until you go back to the plan?" = "Börja ladda direkt, tills du går tillbaka till planen?"; +"Stop charging until you go back to the plan?" = "Sluta ladda tills du går tillbaka till planen?"; +"Let FTW decide when to charge again?" = "Låta FTW bestämma när det laddar igen?"; +"Run the home as %@?" = "Köra hemmet som %@?"; +"Cancel" = "Avbryt"; + +"Price now" = "Pris nu"; +"Cheap now" = "Billigt nu"; +"until %@" = "till %@"; +"Cheap power" = "Billig el"; +"Shows up in the Smart Stack while power is at its cheapest." = "Syns i Smart Stack när elen är som billigast."; +"FTW plan" = "FTW-plan"; +"The next 24 hours on a dial, and what FTW plans now." = "De närmaste 24 timmarna på en tavla, och vad FTW planerar nu."; diff --git a/appleApp/FTWKit/Sources/FTWShared/Watch/WatchDial.swift b/appleApp/FTWKit/Sources/FTWShared/Watch/WatchDial.swift new file mode 100644 index 0000000..5353d87 --- /dev/null +++ b/appleApp/FTWKit/Sources/FTWShared/Watch/WatchDial.swift @@ -0,0 +1,202 @@ +import Foundation + +/// The plan dial as the watch draws it: one turn is the next 24 hours, on a +/// 24-hour face with 00 at the bottom and 12 at the top, so the day's +/// light sits in the upper half and morning on the left. Each quarter hour +/// is one tick. Its length is the price in eight levels, its colour what +/// the battery does then, and a dotted arc inside marks the car's charging. +/// +/// Built on the chart engine in `Charts/` (`SlotGrid`, `ZoneClock`), and +/// worked out once per plan, so the Crown only reads a table. The phone's +/// own dial (WP11) is not on `main` yet; when it lands in FTWShared, the +/// watch draws that face and this type goes. +public struct WatchDial: Equatable, Sendable { + public static let quarterMs: Double = 15 * 60_000 + /// One turn: 24 hours of quarters. + public static let count = 96 + /// The block-glyph scale: ▁▂▃▄▅▆▇█. + public static let levels = 8 + + public enum Battery: Equatable, Sendable { + case stores, gives, waits + } + + public struct Quarter: Equatable, Sendable { + public let startMs: Double + /// Minor units per kWh, the consumer total. Nil before the market + /// has priced it: the dial draws a stub, never a level. + public let minor: Double? + /// 0 to 7 within this face's prices. + public let level: Int? + public let battery: Battery + /// The battery's planned power, site sign. Nil without a plan. + public let batteryW: Double? + public let car: Bool + /// The goal, on the car's last quarter of a window. + public let carGoalPct: Double? + } + + public let grid: SlotGrid + public let quarters: [Quarter] + public let nowMs: Double + public let currency: String + public let zone: TimeZone + /// The cheapest two hours ahead, as quarter indices, when they stand + /// out from the rest. + public let cheapest: ClosedRange? + /// Where the Crown gives a light tap: now, the cheapest window's + /// edges and each car goal. + public let landmarks: Set + /// The box sent a battery plan. + public let hasPlan: Bool + + /// The face at `nowMs`. Prices come from the price record where it has + /// them, else from the plan's own. + public init(plan: PlanEntry?, prices: PriceEntry?, nowMs: Double) { + let zoneID = plan?.timeZone ?? prices?.timeZone + let zone = zoneID.flatMap(TimeZone.init(identifier:)) ?? .current + let clock = ZoneClock.of(zone) + let first = clock.alignDown(nowMs, stepMs: Self.quarterMs) + let grid = SlotGrid(firstMs: first, stepMs: Self.quarterMs, count: Self.count, fromMs: first, toMs: first + Double(Self.count) * Self.quarterMs) + + let slots = (prices?.prices.slots.isEmpty == false ? prices!.prices.slots : plan?.prices ?? []) + .filter { $0.totalMinor.isFinite } + .sorted { $0.startMs < $1.startMs } + let battery = (plan?.battery ?? []).sorted { $0.startMs < $1.startMs } + let charges = plan?.charges ?? [] + + func price(_ t: Double) -> Double? { + // The last slot that starts at or before t, if it still covers t. + guard let i = Self.last(slots, atOrBefore: t, start: \.startMs) else { return nil } + return t < slots[i].startMs + slots[i].durationMs ? slots[i].totalMinor : nil + } + func batteryW(_ t: Double) -> Double? { + guard let i = Self.last(battery, atOrBefore: t, start: \.startMs) else { return nil } + return t < battery[i].startMs + battery[i].durationMs ? battery[i].batteryW : nil + } + + let minors = (0.. first && c.fromMs < grid.toMs { + if let pct = c.targetPct, let i = grid.index(atMs: c.toMs - 1) { goals[i] = pct } + } + + quarters = (0.. PlanGlance.idleW ? .stores : ($0 < -PlanGlance.idleW ? .gives : .waits) } ?? .waits + return Quarter( + startMs: start, + minor: minors[i], + level: minors[i].map { Self.level($0, lo: lo, hi: hi) }, + battery: action, + batteryW: w, + car: charges.contains { $0.fromMs < end && $0.toMs > start }, + carGoalPct: goals[i] + ) + } + + var cheapest: ClosedRange? + let ahead = slots.filter { $0.startMs + $0.durationMs > nowMs && $0.startMs < grid.toMs } + if ahead.count >= 2, let block = PriceStrip.bestBlock(ahead, hours: 2, cheapest: true) { + let mean = ahead.map(\.totalMinor).reduce(0, +) / Double(ahead.count) + if block.meanMinor < mean * PlanGlance.cheapShare || block.meanMinor < 0, + let a = grid.index(atMs: max(block.startMs, nowMs)), let b = grid.index(atMs: block.endMs - 1) { + cheapest = a...b + } + } + + var marks: Set = [0] + if let cheapest { marks.formUnion([cheapest.lowerBound, cheapest.upperBound]) } + marks.formUnion(goals.keys) + + self.grid = grid + self.nowMs = nowMs + self.currency = prices?.prices.currency ?? plan?.currency ?? "SEK" + self.zone = zone + self.cheapest = cheapest + landmarks = marks + hasPlan = !battery.isEmpty + } + + /// The price in eight levels, the dearest at 7. A flat day sits in the + /// middle rather than at either end. + static func level(_ v: Double, lo: Double, hi: Double) -> Int { + guard hi - lo >= 0.5 else { return levels / 2 - 1 } + return min(levels - 1, max(0, Int(((v - lo) / (hi - lo) * Double(levels)).rounded(.down)))) + } + + /// The index of the last element starting at or before `t`, by one + /// binary search over sorted starts. + static func last(_ items: [T], atOrBefore t: Double, start: KeyPath) -> Int? { + var lo = 0, hi = items.count + while lo < hi { + let mid = (lo + hi) / 2 + if items[mid][keyPath: start] <= t { lo = mid + 1 } else { hi = mid } + } + return lo == 0 ? nil : lo - 1 + } + + // MARK: Where things sit + + /// Radians clockwise from the top for a moment on the household clock: + /// 00 at the bottom, 06 on the left, 12 at the top, 18 on the right. + public func angle(_ ms: Double) -> Double { + let clock = ZoneClock.of(zone) + let day = 86_400_000.0 + var local = (ms + clock.offsetMs(ms)).truncatingRemainder(dividingBy: day) + if local < 0 { local += day } + return (local / day * 2 * .pi + .pi).truncatingRemainder(dividingBy: 2 * .pi) + } + + /// Where the middle of quarter `i` sits. + public func angle(quarter i: Int) -> Double { angle(grid.startMs(i) + Self.quarterMs / 2) } + + /// Where the hand points: now, or the start of the quarter the Crown + /// chose. + public func handAngle(selected: Int?) -> Double { + guard let i = selected, i > 0 else { return angle(nowMs) } + return angle(grid.startMs(i)) + } + + /// How faint quarter `i` is: full now, fading toward +24 h, so the + /// seam where tomorrow meets now reads as the end of the turn. + public func fade(_ i: Int) -> Double { 1 - 0.55 * Double(i) / Double(Self.count - 1) } + + // MARK: What the Crown reads + + /// "14:30" + public func time(_ i: Int) -> String { + ZoneClock.of(zone).label(i == 0 ? nowMs : grid.startMs(i)) + } + + /// "98 öre", or "—" before the price is known. + public func price(_ i: Int) -> String { + guard let minor = quarters[i].minor else { return "—" } + return "\(Words.decimal(PriceStrip.text(minor, currency))) \(PriceUnits.unit(currency).label)" + } + + /// What the plan does in quarter `i`: "Battery stores 3.0 kW · car + /// charges", "Battery waits · cheapest". + public func what(_ i: Int) -> String { + let q = quarters[i] + var parts = [String]() + switch (q.battery, q.batteryW) { + case (_, nil): parts.append(WatchWord.noPlan.text) + case (.stores, let w?): parts.append("\(WatchWord.batteryStores.text) \(Words.decimal(PowerFormat.text(w)))") + case (.gives, let w?): parts.append("\(WatchWord.batteryGives.text) \(Words.decimal(PowerFormat.text(w)))") + case (.waits, _): parts.append(WatchWord.batteryWaits.text) + } + if let goal = q.carGoalPct { + parts.append(WatchWord.carGoal.format(PlanGlance.percent(goal))) + } else if q.car { + parts.append(WatchWord.carCharges.text) + } + if let cheapest, cheapest.contains(i) { parts.append(WatchWord.cheapestMark.text) } + if q.minor == nil { parts.append(WatchWord.noPriceYet.text.lowercased()) } + return parts.joined(separator: " · ") + } +} diff --git a/appleApp/FTWKit/Sources/FTWShared/Watch/WatchGlance.swift b/appleApp/FTWKit/Sources/FTWShared/Watch/WatchGlance.swift new file mode 100644 index 0000000..323cb47 --- /dev/null +++ b/appleApp/FTWKit/Sources/FTWShared/Watch/WatchGlance.swift @@ -0,0 +1,263 @@ +import Foundation + +/// What each watch screen says, worked out from the records alone, so the +/// app, its complications and a test agree. +/// +/// Light means live, on the wrist too. A reading glows only when it came +/// from the phone as a live frame (`WatchNews.now`) a moment ago, over a +/// live carrier with its own source live. Everything read from the +/// context or the watch's cache is ink, with the time it was read. +public enum WatchGlance { + /// How long a forwarded frame stays lit after it arrived: the relay's + /// gap rule (3.5 s), with room for the hop from the phone. + public static let liveForMs: Double = 4_000 + /// How long a complication shows the battery's level after the phone + /// read it. Past this the level may have moved, and the centre is + /// left empty. + public static let levelForMs: Double = 15 * 60_000 + + // MARK: Now + + /// Whether Now is live at `nowMs`. `arrivedAtMs` is when the frame + /// reached the watch, by the watch's clock; nil for a record from the + /// context or the cache, which never glows. + public static func lit(_ record: CacheRecord?, arrivedAtMs: Double?, nowMs: Double) -> Bool { + guard let record, let arrivedAtMs else { return false } + guard [.relay, .webrtc, .cloud].contains(record.carrier), record.srcState == .live else { return false } + return nowMs - arrivedAtMs >= -1_000 && nowMs - arrivedAtMs <= liveForMs + } + + /// One figure on Now. + public struct Figure: Equatable, Sendable, Identifiable { + public let node: WatchNow.Node + public let title: String + /// "2,3", or "—" without a reading. + public let value: String + public let unit: String + public let word: String + /// "64 %" for the battery and the car. + public let level: String? + /// Lit: the frame is live and so is this reading. + public let lit: Bool + public var id: WatchNow.Node { node } + } + + public static func figures(_ now: WatchNow?, lit: Bool) -> [Figure] { + guard let now else { return [] } + return now.readings.map { r in + let parts = r.watts.map(PowerFormat.parts) + var level: String? + if let pct = r.levelPct { + let text = PlanGlance.percent(pct) + level = r.levelAssumed ? WatchWord.levelAssumed.format(text) : text + } + return Figure( + node: r.node, + title: title(r.node), + value: parts.map { Words.decimal($0.text) } ?? "—", + unit: parts?.unit ?? "", + word: word(r.way), + level: level, + lit: lit && r.live + ) + } + } + + public static func title(_ node: WatchNow.Node) -> String { + switch node { + case .solar: return WatchWord.solar.text + case .battery: return WatchWord.battery.text + case .home: return WatchWord.home.text + case .grid: return WatchWord.grid.text + case .car: return WatchWord.car.text + } + } + + public static func word(_ way: WatchNow.Way) -> String { + switch way { + case .making: return WatchWord.making.text + case .resting: return WatchWord.resting.text + case .charging: return WatchWord.charging.text + case .giving: return WatchWord.giving.text + case .using: return WatchWord.using.text + case .buying: return WatchWord.buying.text + case .selling: return WatchWord.selling.text + case .balanced: return WatchWord.balanced.text + case .noReading: return WatchWord.noReading.text + } + } + + /// The mono line under the title, in the phone's words: `● LIVE · FTW + /// IN CONTROL`, or `○ AS OF 14:05 · FTW IN CONTROL`. It never counts + /// seconds; a cached reading names the minute it was read. + public struct Status: Equatable, Sendable { + public let lit: Bool + public let text: String + /// The second half is a warning. + public let alarm: Bool + } + + public static func status(_ record: CacheRecord?, lit: Bool, demo: Bool, zone: TimeZone) -> Status { + guard let record else { return Status(lit: false, text: WatchWord.noReadingYet.text, alarm: false) } + var parts = [String]() + if lit { + parts.append(demo ? WatchWord.demo.text : WatchWord.live.text) + } else { + parts.append(WatchWord.asOf.format(ZoneClock.of(zone).label(record.readAtMs))) + } + var alarm = false + switch record.value.control { + case .inControl: parts.append(WatchWord.inControl.text) + case .notInControl: + parts.append(WatchWord.notInControl.text) + alarm = true + case .unknown: break + } + return Status(lit: lit, text: parts.joined(separator: " · "), alarm: alarm) + } + + /// The household's zone, from whichever record names it. + public static func zone(_ ctx: WatchContext?) -> TimeZone { + let id = ctx?.plan?.value.timeZone ?? ctx?.prices?.value.timeZone + return id.flatMap(TimeZone.init(identifier:)) ?? .current + } + + // MARK: The car + + public struct Car: Equatable, Sendable, Identifiable { + public let loadpointID: String + /// Whole percent, when the box has a level. + public let levelPct: Double? + /// The 10-cell meter: how many cells are full. + public let cells: Int? + /// "64 %", or "64 % assumed". + public let levelText: String? + /// "Charge now on", "Charging · 7,4 kW", "Plugged in", "Not plugged in". + public let state: String + public let holding: Bool + public let pluggedIn: Bool + /// "Goal 80 %" + public let goal: String? + /// "Charges 02–05": the next window the plan gives it. + public let next: String? + /// "AS OF 14:05" + public let asOf: String + public var id: String { loadpointID } + + /// The page's shape, drawn redacted while nothing is known. Never + /// shown as a car. + public static let shape = Car(loadpointID: "", levelPct: 50, cells: 5, levelText: "50 %", state: WatchWord.pluggedIn.text, holding: false, pluggedIn: true, goal: WatchWord.goal.format("80 %"), next: nil, asOf: WatchWord.asOf.format("00:00")) + } + + public static func cars(_ record: CacheRecord?, plan: PlanEntry?, nowMs: Double, zone: TimeZone) -> [Car] { + guard let record else { return [] } + let clock = ZoneClock.of(zone) + return record.value.cars.map { c in + let assumed = c.socSource == "assumed" + let level = c.socPct.map { PlanGlance.percent($0) } + let state: String + if c.holding == true { + state = WatchWord.chargeNowOn.text + } else if c.pluggedIn, c.powerW > PlanGlance.idleW { + state = WatchWord.chargingAt.format(Words.decimal(PowerFormat.text(c.powerW))) + } else { + state = c.pluggedIn ? WatchWord.pluggedIn.text : WatchWord.notPluggedIn.text + } + let window = plan?.charges + .filter { $0.loadpointID == c.loadpointID && $0.toMs > nowMs } + .min { $0.fromMs < $1.fromMs } + let next = window.map { w in + WatchWord.chargesAt.format("\(w.fromMs <= nowMs ? PlanWord.nowLower.text : clock.label(w.fromMs))–\(clock.label(w.toMs))") + } + return Car( + loadpointID: c.loadpointID, + levelPct: c.socPct, + cells: c.socPct.map { min(10, max(0, Int(($0 / 10).rounded(.down)))) }, + levelText: level.map { assumed ? WatchWord.levelAssumed.format($0) : $0 }, + state: state, + holding: c.holding == true, + pluggedIn: c.pluggedIn, + goal: c.targetSocPct.map { WatchWord.goal.format(PlanGlance.percent($0)) }, + next: next, + asOf: WatchWord.asOf.format(clock.label(record.readAtMs)) + ) + } + } + + // MARK: Cheap power + + /// A stretch of cheap power, for the Smart Stack. + public struct CheapWindow: Equatable, Sendable, Identifiable { + public let startMs: Double + public let endMs: Double + public let meanMinor: Double + /// "02–04" + public let range: String + /// "28,5 öre" + public let price: String + public var id: Double { startMs } + } + + /// The cheapest two hours left in each day the prices cover, when they + /// stand out from the rest of that day: today from now on, as the price + /// page says it, and tomorrow whole. + public static func cheapWindows(_ entry: PriceEntry?, nowMs: Double) -> [CheapWindow] { + guard let entry else { return [] } + let zone = entry.timeZone.flatMap(TimeZone.init(identifier:)) ?? .current + let clock = ZoneClock.of(zone) + let slots = entry.prices.slots.filter { $0.totalMinor.isFinite }.sorted { $0.startMs < $1.startMs } + let ahead = slots.filter { $0.startMs + $0.durationMs > nowMs } + let days = Dictionary(grouping: ahead) { clock.calendar.startOfDay(for: Date(timeIntervalSince1970: $0.startMs / 1000)) } + var out = [CheapWindow]() + for day in days.keys.sorted() { + let daySlots = days[day] ?? [] + guard daySlots.count >= 2, let block = PriceStrip.bestBlock(daySlots, hours: 2, cheapest: true) else { continue } + let mean = daySlots.map(\.totalMinor).reduce(0, +) / Double(daySlots.count) + guard block.meanMinor < mean * PlanGlance.cheapShare || block.meanMinor < 0 else { continue } + let currency = entry.prices.currency + out.append(CheapWindow( + startMs: block.startMs, + endMs: block.endMs, + meanMinor: block.meanMinor, + range: "\(clock.label(block.startMs))–\(clock.label(block.endMs))", + price: "\(Words.decimal(PriceStrip.text(block.meanMinor, currency))) \(PriceUnits.unit(currency).label)" + )) + } + return out + } + + // MARK: Complications + + /// One complication entry: the dial, the battery's level while it is + /// recent enough to show, and the plan and the price in words. + public struct Face: Equatable, Sendable { + public let atMs: Double + public let dial: WatchDial? + public let batteryPct: Double? + public let plan: PlanGlance.Moment + public let price: PriceGlance.Moment + } + + /// Entries for the plan complication: one now and one each quarter hour + /// ahead while the plan or the prices reach, at most a day. + public static func faces(plan: CacheRead?, prices: CacheRead?, now: CacheRead?, nowMs: Double) -> [Face] { + let ends = [plan?.value.battery?.map { $0.startMs + $0.durationMs }.max(), prices?.value.prices.slots.map { $0.startMs + $0.durationMs }.max()].compactMap { $0 } + let until = min(nowMs + 24 * 3_600_000, ends.max() ?? nowMs) + var times = [nowMs] + var t = (nowMs / WatchDial.quarterMs).rounded(.down) * WatchDial.quarterMs + WatchDial.quarterMs + while t < until { + times.append(t) + t += WatchDial.quarterMs + } + let level = now?.value.reading(.battery)?.levelPct + return times.map { at in + Face( + atMs: at, + dial: plan == nil && prices == nil ? nil : WatchDial(plan: plan?.value, prices: prices?.value, nowMs: at), + batteryPct: now.flatMap { at - $0.record.readAtMs <= levelForMs ? level : nil }, + plan: PlanGlance.moment(plan, atMs: at), + price: PriceGlance.moment(prices, atMs: at) + ) + } + } +} diff --git a/appleApp/FTWKit/Sources/FTWShared/Watch/WatchWire.swift b/appleApp/FTWKit/Sources/FTWShared/Watch/WatchWire.swift new file mode 100644 index 0000000..f1ea622 --- /dev/null +++ b/appleApp/FTWKit/Sources/FTWShared/Watch/WatchWire.swift @@ -0,0 +1,338 @@ +import Foundation + +// What the iPhone and its Apple Watch say to each other over +// WatchConnectivity. The watch cannot open the relay (TN3135: no WebSocket +// and no Network connections for an ordinary watch app), and it holds no +// key. So the phone sends it the same records its widgets read, and the +// watch sends back intent. The phone runs every command over its own +// session with the box, and the box decides. +// +// Plain values, so the app, the watch, its widgets and a test all read the +// same rules. + +/// Now as the phone last read it: one reading per node, whether FTW is in +/// control, and how the home is run. Kept on the watch like any other +/// record, so it reads back as `.cache` and never glows. +public struct WatchNow: CacheValue { + public static let name = "now" + public static let version = 1 + + public enum Node: String, Codable, Sendable, CaseIterable { + case solar, battery, home, grid, car + } + + /// The word beside a figure. The sign is in the word, never a minus. + public enum Way: String, Codable, Sendable { + case making, resting, charging, giving, using, buying, selling, balanced, noReading + } + + public struct Reading: Codable, Sendable, Equatable { + public var node: Node + /// Watts as shown, never signed. Nil when the box sends no field. + public var watts: Double? + public var way: Way + /// The battery's charge or the car's level, whole percent. + public var levelPct: Double? + /// The car's level is FTW's assumption, not a reading. + public var levelAssumed: Bool + /// The phone saw this reading live: a live carrier and its own + /// source live. The watch adds its own rule on arrival. + public var live: Bool + + public init(node: Node, watts: Double?, way: Way, levelPct: Double? = nil, levelAssumed: Bool = false, live: Bool) { + self.node = node + self.watts = watts + self.way = way + self.levelPct = levelPct + self.levelAssumed = levelAssumed + self.live = live + } + } + + public enum Control: String, Codable, Sendable { + case inControl, notInControl, unknown + } + + public var readings: [Reading] + public var control: Control + + public init(readings: [Reading], control: Control) { + self.readings = readings + self.control = control + } + + public func reading(_ node: Node) -> Reading? { readings.first { $0.node == node } } +} + +/// How the home is run, and what the watch may ask for: the box's modes in +/// its order, the one it reports, and whether this phone may change it. +public struct WatchModes: Codable, Sendable, Equatable { + public struct Mode: Codable, Sendable, Equatable, Identifiable { + public var key: String + /// The mode's name in the phone's language. + public var name: String + /// One of the planner's modes: "Use the plan". + public var isPlan: Bool + public var id: String { key } + + public init(key: String, name: String, isPlan: Bool) { + self.key = key + self.name = name + self.isPlan = isPlan + } + } + + public var modes: [Mode] + /// The mode the box reported when the phone wrote this. + public var shown: String? + /// "Plan · Balanced", or a manual mode's name. + public var label: String? + /// The box offers dispatch and this phone's grant carries the scope. + public var mayControl: Bool + + public init(modes: [Mode], shown: String?, label: String?, mayControl: Bool) { + self.modes = modes + self.shown = shown + self.label = label + self.mayControl = mayControl + } +} + +/// Everything the watch shows, as one value the phone replaces whenever it +/// writes its cache (`updateApplicationContext`). Each record keeps where it +/// came from and when, as in the App Group. +public struct WatchContext: Codable, Sendable, Equatable { + public static let version = 1 + /// The key in the application context and the complication's user info. + public static let key = "ctx" + + public var version: Int + /// The site, or "demo" for the simulated home. Empty: the phone has no + /// home, and the watch forgets the one it had. + public var home: String + public var isDemo: Bool + public var prices: CacheRecord? + public var cars: CacheRecord? + public var plan: CacheRecord? + public var now: CacheRecord? + public var modes: WatchModes? + /// The last command from the watch, as far as the box has answered it, + /// so a watch that slept meanwhile still sees how it ended. + public var receipt: WatchReceipt? + /// Phone clock when the phone sent this. + public var sentAtMs: Double + + public init(home: String, isDemo: Bool, prices: CacheRecord? = nil, cars: CacheRecord? = nil, plan: CacheRecord? = nil, now: CacheRecord? = nil, modes: WatchModes? = nil, receipt: WatchReceipt? = nil, sentAtMs: Double) { + version = Self.version + self.home = home + self.isDemo = isDemo + self.prices = prices + self.cars = cars + self.plan = plan + self.now = now + self.modes = modes + self.receipt = receipt + self.sentAtMs = sentAtMs + } + + /// The phone has signed out, or never paired. + public static func empty(sentAtMs: Double) -> WatchContext { + WatchContext(home: "", isDemo: false, sentAtMs: sentAtMs) + } + + public var hasHome: Bool { !home.isEmpty } + + /// The same records, whenever the phone sent them. A resend of this + /// tells the watch nothing new. + public func sameAs(_ other: WatchContext) -> Bool { + var a = self, b = other + a.sentAtMs = 0 + b.sentAtMs = 0 + return a == b + } + + /// JSON, then LZFSE: a day of quarters for the plan and the prices is + /// tens of kilobytes as JSON and a few once packed. + public func packed() throws -> Data { + try (JSONEncoder().encode(self) as NSData).compressed(using: .lzfse) as Data + } + + /// Nil for anything this build cannot read: another version, or bytes + /// that are not a context. + public static func unpacked(_ data: Data) -> WatchContext? { + guard let json = try? (data as NSData).decompressed(using: .lzfse) as Data, + let ctx = try? JSONDecoder().decode(WatchContext.self, from: json), + ctx.version == version else { return nil } + return ctx + } +} + +/// A command from the watch: what to do, to which home, and until when it +/// still means something. The phone builds the box's `cmd` and checks the +/// home and the time; the watch never names an op the box knows. +/// +/// The watch makes the `cmdId`, a UUIDv7 as the phone's own are, and the +/// phone sends the box that id. When the watch can also reach the box +/// itself (ADR 0009), a command tried both ways still runs once. +public struct WatchCommand: Codable, Sendable, Equatable, Identifiable { + public enum Op: Codable, Sendable, Equatable { + case chargeNow(loadpointID: String) + case pause(loadpointID: String) + case backToPlan(loadpointID: String) + case mode(String) + } + + /// How long a command stays worth running. A tap that reaches the + /// phone later than this is dropped, not run late. + public static let lifeMs: Double = 30_000 + + /// The box's `cmdId`. + public var id: String + public var op: Op + /// The home the watch showed when it was tapped. + public var home: String + public var notValidAfterMs: Double + + public init(id: String? = nil, op: Op, home: String, sentAtMs: Double) { + self.id = id ?? Self.newID(nowMs: sentAtMs) + self.op = op + self.home = home + notValidAfterMs = sentAtMs + Self.lifeMs + } + + /// A UUIDv7: the time in its first 48 bits, the rest random. + public static func newID(nowMs: Double) -> String { + var g = SystemRandomNumberGenerator() + var bytes = (0..<16).map { _ in UInt8.random(in: 0...255, using: &g) } + 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 = Array(bytes.map { String(format: "%02x", $0) }.joined()) + return "\(String(h[0..<8]))-\(String(h[8..<12]))-\(String(h[12..<16]))-\(String(h[16..<20]))-\(String(h[20...]))" + } + + /// Whether `id` is a UUIDv7 written the way the box expects: lower + /// case, with its version and variant bits. The phone sends nothing + /// else as a `cmdId`. + public static func isID(_ id: String) -> Bool { + let parts = id.split(separator: "-", omittingEmptySubsequences: false) + guard parts.map(\.count) == [8, 4, 4, 4, 12], id.allSatisfy({ $0 == "-" || $0.isHexDigit && !$0.isUppercase }) else { return false } + return parts[2].first == "7" && "89ab".contains(parts[3].first!) + } +} + +/// What the watch says to the phone (`sendMessageData`). +public enum WatchAsk: Codable, Sendable, Equatable { + /// The watch app is on screen: send Now while it looks. + case look + /// It left the screen. + case away + case command(WatchCommand) + + public func data() throws -> Data { try JSONEncoder().encode(self) } + public static func from(_ data: Data) -> WatchAsk? { try? JSONDecoder().decode(WatchAsk.self, from: data) } +} + +/// What the phone says to the watch while the watch app is on screen. +public enum WatchNews: Codable, Sendable, Equatable { + case now(CacheRecord) + case receipt(WatchReceipt) + + public func data() throws -> Data { try JSONEncoder().encode(self) } + public static func from(_ data: Data) -> WatchNews? { try? JSONDecoder().decode(WatchNews.self, from: data) } +} + +/// How far a command from the watch has come: SENT ▸ ACCEPTED ▸ CONFIRMED. +/// +/// Sent is the phone sending the box's `cmd`, accepted the box's +/// `cmd.ack`, confirmed its `cmd.result`. The phone fills every mark from +/// the box's answers. The watch itself can only say that it is sending, +/// that the phone could not be reached, or that no answer came, so it can +/// never show a result the box has not confirmed. +public struct WatchReceipt: Codable, Sendable, Equatable { + public enum Mark: String, Codable, Sendable { + case waiting, active, done, failed + } + + public enum Tone: String, Codable, Sendable { + case working, done, warning, failed + } + + public var commandID: String + public var op: WatchCommand.Op + public var sent: Mark + public var accepted: Mark + public var confirmed: Mark + /// One sentence, ready to show. + public var text: String + public var tone: Tone + + public init(commandID: String, op: WatchCommand.Op, sent: Mark, accepted: Mark, confirmed: Mark, text: String, tone: Tone) { + self.commandID = commandID + self.op = op + self.sent = sent + self.accepted = accepted + self.confirmed = confirmed + self.text = text + self.tone = tone + } + + /// The command has settled, one way or the other. + public var settled: Bool { tone != .working } + + /// How long the watch waits for the phone before it says no answer + /// came. The box has 20 s to answer and the phone needs a moment to + /// wake and connect. + public static let answerMs: Double = 45_000 + + /// At the tap: on its way to the phone. + public static func tapped(_ c: WatchCommand) -> WatchReceipt { + WatchReceipt(commandID: c.id, op: c.op, sent: .active, accepted: .waiting, confirmed: .waiting, text: WatchWord.sendingToPhone.text, tone: .working) + } + + /// The phone has the command and is sending it to the box. + public static func phoneHasIt(_ c: WatchCommand) -> WatchReceipt { + WatchReceipt(commandID: c.id, op: c.op, sent: .active, accepted: .waiting, confirmed: .waiting, text: WatchWord.phoneSending.text, tone: .working) + } + + /// The phone could not be reached: nothing went to the box. + public static func unreachable(_ c: WatchCommand) -> WatchReceipt { + WatchReceipt(commandID: c.id, op: c.op, sent: .failed, accepted: .waiting, confirmed: .waiting, text: WatchWord.phoneUnreachableNothingSent.text, tone: .failed) + } + + /// No word from the phone within `answerMs`. It may still have run, so + /// this is a warning, and the marks stay as the phone last left them. + public func noAnswer() -> WatchReceipt { + guard !settled else { return self } + var r = self + if r.sent == .active { r.sent = .waiting } + r.text = WatchWord.noAnswerCheckPhone.text + r.tone = .warning + return r + } + + /// What to show once `next` arrives. Another command's receipt is not + /// this one's, and a settled receipt keeps its end. + public func taking(_ next: WatchReceipt) -> WatchReceipt? { + guard next.commandID == commandID else { return nil } + if settled, next.tone == .working { return nil } + return next + } +} + +extension PriceEntry { + /// Whether `new` brings a day `old` did not have: it reaches past the + /// end of today on the household clock, and further than before. The + /// phone then wakes the watch's complication, which may do so only 50 + /// times a day. + public static func bringsNewDay(old: PriceEntry?, new: PriceEntry?, nowMs: Double) -> Bool { + guard let new, let newEnd = new.prices.slots.map({ $0.startMs + $0.durationMs }).max() else { return false } + let oldEnd = old?.prices.slots.map { $0.startMs + $0.durationMs }.max() ?? 0 + let calendar = Calendar.household(new.timeZone) + let today = calendar.startOfDay(for: Date(timeIntervalSince1970: nowMs / 1000)) + guard let tomorrow = calendar.date(byAdding: .day, value: 1, to: today) else { return false } + let midnight = tomorrow.timeIntervalSince1970 * 1000 + return newEnd > midnight && newEnd > oldEnd && oldEnd <= midnight + } +} diff --git a/appleApp/FTWKit/Sources/FTWShared/Watch/WatchWords.swift b/appleApp/FTWKit/Sources/FTWShared/Watch/WatchWords.swift new file mode 100644 index 0000000..0c57dd8 --- /dev/null +++ b/appleApp/FTWKit/Sources/FTWShared/Watch/WatchWords.swift @@ -0,0 +1,106 @@ +import Foundation + +/// Every sentence the watch shows that the phone's widgets do not. The +/// English is the key; the Swedish lives in `Resources/sv.lproj/Watch.strings`, +/// and a test checks each one has it. The status line and the receipt use +/// the phone's words (`LIVE · FTW STYR`, `SKICKAT ▸ MOTTAGET ▸ BEKRÄFTAT`), +/// so the two screens say the same thing. +public enum WatchWord: String, CaseIterable, Sendable { + // The status line, in the terminal's caps. + case live = "LIVE" + case demo = "DEMO" + case asOf = "AS OF %@" + case noReadingYet = "NO READING YET" + case inControl = "FTW IN CONTROL" + case notInControl = "▲ FTW NOT IN CONTROL" + case planned = "PLANNED %@" + case planFrom = "PLAN FROM %@" + + // The receipt. + case sent = "SENT" + case accepted = "ACCEPTED" + case confirmed = "CONFIRMED" + case sendingToPhone = "Sending to your iPhone…" + case phoneSending = "Your iPhone is sending it to your box…" + case phoneUnreachableNothingSent = "Your iPhone can't be reached. Nothing was sent." + case noAnswerCheckPhone = "No answer from your iPhone yet. Check FTW there." + case tooLate = "That came too late, so it was not sent. Try again." + case otherHome = "That was for another home. Nothing was sent." + case cannotChangeMode = "This phone may not change how the home is run." + + // Where the watch stands. + case phoneUnreachable = "iPhone not reachable" + case openOnPhone = "Open FTW on your iPhone" + case openOnPhoneDetail = "The watch shows what your iPhone last read. Pair the phone with your box first." + + // Now's nodes and their words. + case solar = "Solar" + case battery = "Battery" + case home = "Usage" + case grid = "Grid" + case car = "Car" + case making = "making" + case resting = "resting" + case charging = "charging" + case giving = "giving power" + case using = "using" + case buying = "buying" + case selling = "selling" + case balanced = "balanced" + case noReading = "no reading" + + // The pages. + case nowTitle = "Now" + case planTitle = "Plan" + case priceTitle = "Price" + case mode = "Mode" + + // The plan dial. + case batteryStores = "Battery stores" + case batteryGives = "Battery gives power" + case batteryWaits = "Battery waits" + case noPlan = "No plan" + case carCharges = "car charges" + case carGoal = "car %@" + case cheapestMark = "cheapest" + case noPriceYet = "No price yet" + case turnTheCrown = "Turn the Crown to step through the plan" + + // The car. + case pluggedIn = "Plugged in" + case notPluggedIn = "Not plugged in" + case chargeNowOn = "Charge now on" + case chargingAt = "Charging · %@" + case goal = "Goal %@" + case chargesAt = "Charges %@" + case levelAssumed = "%@ assumed" + case noCharger = "FTW has no charger" + case chargeNow = "Charge now" + case pause = "Pause" + case backToPlan = "Back to the plan" + case chargeNowAsk = "Start charging at once, until you go back to the plan?" + case pauseAsk = "Stop charging until you go back to the plan?" + case backToPlanAsk = "Let FTW decide when to charge again?" + case modeAsk = "Run the home as %@?" + case cancel = "Cancel" + + // The price. + case priceNow = "Price now" + case cheapNow = "Cheap now" + case cheapUntil = "until %@" + case cheapWidget = "Cheap power" + case cheapWidgetSummary = "Shows up in the Smart Stack while power is at its cheapest." + case planWidget = "FTW plan" + case planWidgetSummary = "The next 24 hours on a dial, and what FTW plans now." + + public var text: String { Words.bundle.localizedString(forKey: rawValue, value: rawValue, table: "Watch") } + + public func format(_ args: String...) -> String { + var out = text + for a in args { + guard let r = out.range(of: "%@") else { break } + out.replaceSubrange(r, with: a) + } + return out + } +} diff --git a/appleApp/FTWKit/Tests/FTWKitTests/RemoteTests.swift b/appleApp/FTWKit/Tests/FTWKitTests/RemoteTests.swift index 97d7760..aab596b 100644 --- a/appleApp/FTWKit/Tests/FTWKitTests/RemoteTests.swift +++ b/appleApp/FTWKit/Tests/FTWKitTests/RemoteTests.swift @@ -515,6 +515,7 @@ final class RecordingCarrier: Carrier { var args: CBOR var notValidAfterMs: Double? var uptimeMs: Double + var cmdId: String? } let inner: Carrier @@ -532,7 +533,7 @@ final class RecordingCarrier: Carrier { @discardableResult func send(_ frame: Bytes) -> Bool { if let d = try? Frame.decode(frame), d.envelope.t == "cmd", let b = d.envelope.b { - commands.append(Command(op: b["op"]?.string ?? "", args: b["args"] ?? CBOR.emptyMap, notValidAfterMs: b["notValidAfterMs"]?.double, uptimeMs: uptimeMs)) + commands.append(Command(op: b["op"]?.string ?? "", args: b["args"] ?? CBOR.emptyMap, notValidAfterMs: b["notValidAfterMs"]?.double, uptimeMs: uptimeMs, cmdId: b["cmdId"]?.string)) } return inner.send(frame) } diff --git a/appleApp/FTWKit/Tests/FTWKitTests/ThemeTests.swift b/appleApp/FTWKit/Tests/FTWKitTests/ThemeTests.swift index 22a3c6b..b971a7b 100644 --- a/appleApp/FTWKit/Tests/FTWKitTests/ThemeTests.swift +++ b/appleApp/FTWKit/Tests/FTWKitTests/ThemeTests.swift @@ -17,12 +17,12 @@ import Testing /// One colour set, by look. A look the set does not name falls back the /// way the asset catalogue does: high contrast to normal, dark to light. - func color(_ role: String, _ look: Look, in path: String? = nil) throws -> RGB { + func color(_ role: String, _ look: Look, in path: String? = nil, idiom: String = "universal") throws -> RGB { let text = try RepoFiles.read(path ?? "\(Self.catalog)/ftw.\(role).colorset/Contents.json") let json = try #require(try JSONSerialization.jsonObject(with: Data(text.utf8)) as? [String: Any]) let colors = try #require(json["colors"] as? [[String: Any]]) var byLook: [Set: RGB] = [:] - for c in colors { + for c in colors where (c["idiom"] as? String ?? "universal") == idiom { let marks = Set(((c["appearances"] as? [[String: String]]) ?? []).compactMap { $0["value"] }) let color = try #require(c["color"] as? [String: Any]) let comps = try #require(color["components"] as? [String: String]) @@ -100,6 +100,14 @@ import Testing } } + /// watchOS compiles no dark appearance into a catalog, and a watch is + /// always dark. So each role has a watch colour, and it is its dark one. + @Test func theWatchWearsTheDarkHalf() throws { + for role in Self.roles { + #expect(try color(role, .light, idiom: "watch") == color(role, .dark), "\(role)") + } + } + /// The system tint is ink and the launch screen is the ground, in the /// app's own catalog, which the system reads before any view. @Test func theTintIsInkAndTheLaunchIsTheGround() throws { @@ -114,7 +122,7 @@ import Testing /// catalog, so a widget can never drift from the app. @Test func noOrangeAndNoColourInCode() throws { let orange: Set = ["DA7F00", "FFAC41"] - for folder in ["appleApp/FTW", "appleApp/FTWCharts", "appleApp/FTWWidgets", "appleApp/FTWNotify"] { + for folder in ["appleApp/FTW", "appleApp/FTWCharts", "appleApp/FTWWidgets", "appleApp/FTWNotify", "appleApp/FTWWatch", "appleApp/FTWWatchShared", "appleApp/FTWWatchWidgets"] { let root = RepoFiles.root.appendingPathComponent(folder) let files = try #require(FileManager.default.enumerator(at: root, includingPropertiesForKeys: nil)) for case let url as URL in files { diff --git a/appleApp/FTWKit/Tests/FTWKitTests/WatchShareTests.swift b/appleApp/FTWKit/Tests/FTWKitTests/WatchShareTests.swift new file mode 100644 index 0000000..8bff7e8 --- /dev/null +++ b/appleApp/FTWKit/Tests/FTWKitTests/WatchShareTests.swift @@ -0,0 +1,108 @@ +import Foundation +import Testing +@testable import FTWKit +import FTWDemo +import FTWShared + +/// The Apple Watch through this phone, against the simulated box: the +/// records it is handed, and its commands from tap to the box's answer. +@MainActor +@Suite struct WatchShareTests { + typealias Rig = RemoteTests.Rig + + /// Run a watch command the way the bridge does, keeping every step. + func run(_ op: WatchCommand.Op, _ rig: Rig, home: String = "demo", id: String? = nil, sentAtMs: Double? = nil, for ms: Double = 6_000) async throws -> (end: WatchReceipt, steps: [WatchReceipt]) { + let homeModels = try #require(rig.app.home) + let c = WatchCommand(id: id, op: op, home: home, sentAtMs: sentAtMs ?? rig.scheduler.nowMs) + var steps = [WatchReceipt]() + let task = Task { await WatchShare.run(c, home: homeModels, nowMs: rig.scheduler.nowMs) { steps.append($0) } } + await rig.run(ms) + return (await task.value, steps) + } + + /// The box gets the watch's own `cmdId`, and the receipt moves sent, + /// accepted, confirmed: CONFIRMED only once the box's result says so. + @Test func chargeNowFromTheWatchCarriesItsIdAndTheBoxsReceipt() async throws { + try await AppWords.$language.withValue(.en) { + let rig = Rig() + _ = try rig.start() + let id = WatchCommand.newID(nowMs: rig.scheduler.nowMs) + let (end, steps) = try await run(.chargeNow(loadpointID: "garage"), rig, id: id) + let cmd = try #require(rig.wire?.commands.last) + #expect(cmd.cmdId == id) + #expect(cmd.op == Contract.opLoadpointHold) + #expect(end.confirmed == .done && end.tone == .done && end.commandID == id) + // In order, and never confirmed before the box's result. + let confirmedAt = try #require(steps.firstIndex { $0.confirmed == .done }) + let acceptedAt = try #require(steps.firstIndex { $0.accepted == .done }) + #expect(acceptedAt < confirmedAt) + #expect(steps.prefix(confirmedAt).allSatisfy { $0.confirmed != .done && $0.tone == .working }) + #expect(steps.allSatisfy { $0.commandID == id }) + } + } + + @Test func aCommandForAnotherHomeOrTooLateSendsNothing() async throws { + try await AppWords.$language.withValue(.en) { + let rig = Rig() + _ = try rig.start() + let other = try await run(.chargeNow(loadpointID: "garage"), rig, home: "s9", for: 500) + #expect(other.end.tone == .failed && other.end.sent == .failed) + #expect(other.end.text == WatchWord.otherHome.text) + let late = try await run(.pause(loadpointID: "garage"), rig, sentAtMs: rig.scheduler.nowMs - WatchCommand.lifeMs - 1, for: 500) + #expect(late.end.text == WatchWord.tooLate.text) + let odd = try await run(.pause(loadpointID: "garage"), rig, id: UUID().uuidString, for: 500) + #expect(odd.end.tone == .failed) + #expect(rig.wire?.commands.isEmpty ?? true) + } + } + + @Test func pauseAndBackToThePlanFromTheWatch() async throws { + try await AppWords.$language.withValue(.en) { + let rig = Rig() + _ = try rig.start() + let pause = try await run(.pause(loadpointID: "garage"), rig) + #expect(pause.end.confirmed == .done) + #expect(rig.wire?.commands.last?.args["power_w"]?.double == 0) + let back = try await run(.backToPlan(loadpointID: "garage"), rig) + #expect(back.end.confirmed == .done) + #expect(rig.wire?.commands.last?.args["clear"]?.bool == true) + } + } + + @Test func theModeFromTheWatchGoesWithItsId() async throws { + try await AppWords.$language.withValue(.en) { + let rig = Rig() + _ = try rig.start() + await rig.run(2_000) + let home = try #require(rig.app.home) + let modes = try #require(WatchShare.context(home, isDemo: true, nowMs: rig.scheduler.nowMs).modes) + let manual = try #require(modes.modes.first { !$0.isPlan && $0.key != modes.shown }) + let (end, _) = try await run(.mode(manual.key), rig) + #expect(rig.wire?.commands.last?.op == Contract.opSetMode) + #expect(rig.wire?.commands.last?.cmdId == end.commandID) + #expect(end.confirmed == .done) + } + } + + /// The demo's records, dated, and none of them claiming to be the cache + /// while the stream is live; Now's figures as the phone holds them. + @Test func theContextHoldsTheRecordsTheWidgetsWouldRead() async throws { + let rig = Rig() + _ = try rig.start() + let home = try #require(rig.app.home) + let done = WatchShare.looking(home) + defer { done() } + await rig.run(6_000) + let ctx = WatchShare.context(home, isDemo: true, nowMs: rig.scheduler.nowMs) + #expect(ctx.home == "demo" && ctx.isDemo) + let now = try #require(ctx.now) + #expect(now.carrier != .cache) + #expect(now.value.readings.contains { $0.node == .battery && $0.levelPct != nil }) + #expect(ctx.cars?.value.cars.first?.loadpointID == "garage") + #expect(ctx.prices != nil) + #expect(ctx.modes?.mayControl == true) + // Packs small enough to hand over. + #expect(try ctx.packed().count < 40_000) + #expect(WatchShare.context(nil, isDemo: false, nowMs: 0).hasHome == false) + } +} diff --git a/appleApp/FTWKit/Tests/FTWSharedTests/WatchTests.swift b/appleApp/FTWKit/Tests/FTWSharedTests/WatchTests.swift new file mode 100644 index 0000000..0a2308e --- /dev/null +++ b/appleApp/FTWKit/Tests/FTWSharedTests/WatchTests.swift @@ -0,0 +1,325 @@ +import Foundation +import Testing +@testable import FTWShared + +/// The Apple Watch: what the phone hands over, how a command's receipt may +/// move, when a reading may glow, and the plan dial's face. Stockholm's +/// clock; 14 October 2026 is a Wednesday. English: the tests run in the +/// development language. +@Suite struct WatchTests { + static let quarter: Double = 900_000 + static let hour: Double = 3_600_000 + static let zone = "Europe/Stockholm" + let calendar: Calendar = { + var c = Calendar(identifier: .gregorian) + c.timeZone = TimeZone(identifier: WatchTests.zone)! + 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 + } + + /// Today and tomorrow in quarters: 30 öre from 02 to 04 each night, + /// 160 from 17 to 21, 70 otherwise. + func prices(days: Int = 2) -> PriceEntry { + let start = at(14, 0) + let slots = (0..<(96 * days)).map { i -> PriceSlot in + let t = start + Double(i) * Self.quarter + let h = calendar.component(.hour, from: Date(timeIntervalSince1970: t / 1000)) + let p: Double = h == 2 || h == 3 ? 30 : (h >= 17 && h < 21 ? 160 : 70) + return PriceSlot(startMs: t, durationMs: Self.quarter, spotMinor: p - 20, totalMinor: p) + } + return PriceEntry(fromMs: start, prices: Prices(zone: "SE3", currency: "SEK", slots: slots, stale: false), timeZone: Self.zone) + } + + /// The battery stores 22:00–23:30 and gives power 17:00–19:00; the car + /// charges 01:00–04:45 toward 80 %. + func plan() -> PlanEntry { + let start = at(14, 12) + let battery = (0..<96).map { i -> PlanEntry.Slot in + let t = start + Double(i) * Self.quarter + let h = calendar.component(.hour, from: Date(timeIntervalSince1970: t / 1000)) + let m = calendar.component(.minute, from: Date(timeIntervalSince1970: t / 1000)) + let w: Double = h == 22 || (h == 23 && m < 30) ? 3_000 : (h >= 17 && h < 19 ? -2_000 : 40) + return PlanEntry.Slot(startMs: t, durationMs: Self.quarter, batteryW: w, reason: "test") + } + return PlanEntry( + battery: battery, + charges: [.init(loadpointID: "lp1", fromMs: at(15, 1), toMs: at(15, 4, 45), energyWh: 20_000, targetPct: 80)], + prices: [], + currency: "SEK", + boxStale: false, + timeZone: Self.zone + ) + } + + func now(carrier: CarrierKind = .relay, srcState: SourceState = .live, readAtMs: Double, live: Bool = true) -> CacheRecord { + let value = WatchNow(readings: [ + .init(node: .solar, watts: 2_300, way: .making, live: live), + .init(node: .battery, watts: 1_200, way: .giving, levelPct: 64, live: live), + .init(node: .grid, watts: 120, way: .buying, live: false), + ], control: .inControl) + return CacheRecord(siteId: "s1", carrier: carrier, srcState: srcState, readAtMs: readAtMs, value: value) + } + + // MARK: The wire + + @Test func aContextSurvivesThePackingAndShrinks() throws { + let t = at(14, 12) + let ctx = WatchContext( + home: "s1", isDemo: false, + prices: CacheRecord(siteId: "s1", carrier: .relay, srcState: .live, readAtMs: t, value: prices()), + plan: CacheRecord(siteId: "s1", carrier: .relay, srcState: .live, readAtMs: t, value: plan()), + now: now(readAtMs: t), + modes: WatchModes(modes: [.init(key: "planner_self", name: "Plan", isPlan: true)], shown: "planner_self", label: "Plan · Balanced", mayControl: true), + sentAtMs: t + ) + let packed = try ctx.packed() + #expect(WatchContext.unpacked(packed) == ctx) + // A day of quarters twice over still fits WatchConnectivity easily. + let json = try JSONEncoder().encode(ctx) + #expect(packed.count < json.count / 3) + #expect(packed.count < 32_000) + #expect(WatchContext.unpacked(Data("not a context".utf8)) == nil) + } + + @Test func aContextOfAnotherVersionIsNotRead() throws { + var ctx = WatchContext(home: "s1", isDemo: false, sentAtMs: 1) + ctx.version = WatchContext.version + 1 + #expect(WatchContext.unpacked(try ctx.packed()) == nil) + } + + @Test func theSameRecordsSentAgainAreTheSame() { + let a = WatchContext(home: "s1", isDemo: false, prices: nil, sentAtMs: 1) + var b = a + b.sentAtMs = 99 + #expect(a.sameAs(b)) + b.home = "s2" + #expect(!a.sameAs(b)) + #expect(!WatchContext.empty(sentAtMs: 1).hasHome) + } + + @Test func asksAndNewsRoundTrip() throws { + let c = WatchCommand(op: .chargeNow(loadpointID: "lp1"), home: "s1", sentAtMs: 1_000) + #expect(c.notValidAfterMs == 1_000 + WatchCommand.lifeMs) + for ask in [WatchAsk.look, .away, .command(c)] { + #expect(WatchAsk.from(try ask.data()) == ask) + } + let news = WatchNews.receipt(.phoneHasIt(c)) + #expect(WatchNews.from(try news.data()) == news) + } + + /// The watch makes the box's `cmdId`: a UUIDv7 that starts with the time. + @Test func theWatchMakesAUUIDv7() { + let t = at(14, 12) + let id = WatchCommand.newID(nowMs: t) + #expect(WatchCommand.isID(id)) + #expect(id != WatchCommand.newID(nowMs: t)) + let hex = id.replacingOccurrences(of: "-", with: "").prefix(12) + #expect(UInt64(hex, radix: 16) == UInt64(t)) + #expect(WatchCommand(op: .mode("idle"), home: "s1", sentAtMs: t).id.hasPrefix(String(id.prefix(8)))) + #expect(!WatchCommand.isID(UUID().uuidString)) + #expect(!WatchCommand.isID(id.uppercased())) + #expect(!WatchCommand.isID("0190f2c4-aaaa-4bbb-8ccc-dddddddddddd")) + #expect(!WatchCommand.isID("not-a-command")) + } + + // MARK: The receipt + + /// The watch alone can say it is sending, that the phone was out of + /// reach, or that no answer came. None of them marks the box's answer. + @Test func theWatchNeverMarksWhatOnlyTheBoxCan() { + let c = WatchCommand(op: .pause(loadpointID: "lp1"), home: "s1", sentAtMs: 0) + for r in [WatchReceipt.tapped(c), .unreachable(c), .phoneHasIt(c), WatchReceipt.tapped(c).noAnswer()] { + #expect(r.accepted != .done, "\(r.text)") + #expect(r.confirmed != .done, "\(r.text)") + #expect(r.tone != .done, "\(r.text)") + } + #expect(WatchReceipt.unreachable(c).sent == .failed) + #expect(WatchReceipt.tapped(c).noAnswer().tone == .warning) + } + + @Test func aReceiptTakesOnlyItsOwnCommandAndKeepsItsEnd() { + let c = WatchCommand(op: .chargeNow(loadpointID: "lp1"), home: "s1", sentAtMs: 0) + let other = WatchCommand(op: .chargeNow(loadpointID: "lp1"), home: "s1", sentAtMs: 0) + let tapped = WatchReceipt.tapped(c) + #expect(tapped.taking(.phoneHasIt(other)) == nil) + let done = WatchReceipt(commandID: c.id, op: c.op, sent: .done, accepted: .done, confirmed: .done, text: "Charge now is on.", tone: .done) + #expect(tapped.taking(done) == done) + // A late "still working" never undoes the box's answer. + #expect(done.taking(.phoneHasIt(c)) == nil) + #expect(done.noAnswer() == done) + // The phone's answer still lands after the watch gave up waiting. + #expect(tapped.noAnswer().taking(done) == done) + } + + // MARK: Light means live + + @Test func onlyAFreshLiveFrameGlows() { + let t = at(14, 12) + let frame = now(readAtMs: t) + #expect(WatchGlance.lit(frame, arrivedAtMs: t, nowMs: t + 1_000)) + #expect(!WatchGlance.lit(frame, arrivedAtMs: t, nowMs: t + WatchGlance.liveForMs + 1)) + // The context's copy, or anything from the watch's cache, never does. + #expect(!WatchGlance.lit(frame, arrivedAtMs: nil, nowMs: t)) + #expect(!WatchGlance.lit(now(carrier: .cache, readAtMs: t), arrivedAtMs: t, nowMs: t)) + #expect(!WatchGlance.lit(now(srcState: .lagging, readAtMs: t), arrivedAtMs: t, nowMs: t)) + #expect(!WatchGlance.lit(nil, arrivedAtMs: t, nowMs: t)) + #expect(WatchGlance.lit(now(carrier: .cloud, readAtMs: t), arrivedAtMs: t, nowMs: t)) + } + + @Test func aFigureGlowsOnlyWhenItsOwnReadingIsLive() { + let figures = WatchGlance.figures(now(readAtMs: 0).value, lit: true) + #expect(figures.map(\.lit) == [true, true, false]) + #expect(WatchGlance.figures(now(readAtMs: 0).value, lit: false).allSatisfy { !$0.lit }) + #expect(figures[0].value == "2.3" && figures[0].unit == "kW") + #expect(figures[1].level == "64\u{00A0}%") + #expect(figures[2].value == "120" && figures[2].word == "buying") + } + + @Test func theStatusLineSaysLiveOrTheMinuteItWasRead() { + let t = at(14, 10, 5) + let zone = TimeZone(identifier: Self.zone)! + #expect(WatchGlance.status(now(readAtMs: t), lit: true, demo: false, zone: zone).text == "LIVE · FTW IN CONTROL") + #expect(WatchGlance.status(now(readAtMs: t), lit: false, demo: false, zone: zone).text == "AS OF 10:05 · FTW IN CONTROL") + #expect(WatchGlance.status(now(readAtMs: t), lit: true, demo: true, zone: zone).text == "DEMO · FTW IN CONTROL") + #expect(WatchGlance.status(nil, lit: false, demo: false, zone: zone).text == "NO READING YET") + var out = now(readAtMs: t) + out.value.control = .notInControl + let warn = WatchGlance.status(out, lit: true, demo: false, zone: zone) + #expect(warn.alarm && warn.text == "LIVE · ▲ FTW NOT IN CONTROL") + } + + // MARK: The dial + + @Test func theFaceIsTheNextDayInQuarters() { + let t = at(14, 12, 7) + let dial = WatchDial(plan: plan(), prices: prices(), nowMs: t) + #expect(dial.quarters.count == 96) + #expect(dial.quarters[0].startMs == at(14, 12)) + #expect(dial.quarters[95].startMs == at(15, 11, 45)) + #expect(dial.time(0) == "12:07") + #expect(dial.time(4) == "13:00") + #expect(dial.hasPlan) + } + + /// 00 at the bottom, 06 on the left, 12 at the top, 18 on the right. + @Test func midnightSitsAtTheBottomAndNoonAtTheTop() { + let dial = WatchDial(plan: nil, prices: prices(), nowMs: at(14, 12)) + func near(_ a: Double, _ b: Double) -> Bool { abs(a - b) < 1e-9 || abs(abs(a - b) - 2 * .pi) < 1e-9 } + #expect(near(dial.angle(at(14, 0)), .pi)) + #expect(near(dial.angle(at(14, 6)), 1.5 * .pi)) + #expect(near(dial.angle(at(14, 12)), 0)) + #expect(near(dial.angle(at(14, 18)), 0.5 * .pi)) + #expect(near(dial.handAngle(selected: nil), dial.angle(at(14, 12)))) + #expect(near(dial.handAngle(selected: 8), dial.angle(at(14, 14)))) + } + + @Test func eachTickHoldsItsPriceLevelAndWhatTheBatteryDoes() { + let dial = WatchDial(plan: plan(), prices: prices(), nowMs: at(14, 12)) + func q(_ day: Int, _ h: Int, _ m: Int = 0) -> WatchDial.Quarter { dial.quarters[dial.grid.index(atMs: at(day, h, m))!] } + #expect(q(14, 18).level == 7) + #expect(q(15, 2).level == 0) + #expect(q(14, 13).level == 2) + #expect(q(14, 22).battery == .stores) + #expect(q(14, 17).battery == .gives) + #expect(q(14, 13).battery == .waits) + #expect(q(15, 2).car && !q(14, 20).car) + #expect(q(15, 4, 30).carGoalPct == 80) + #expect(dial.what(dial.grid.index(atMs: at(14, 22))!) == "Battery stores 3.0 kW") + #expect(dial.price(dial.grid.index(atMs: at(14, 18))!) == "160 öre") + } + + /// A tick never shows without a price: before tomorrow's prices come, + /// those quarters have no level. + @Test func unpricedQuartersHaveNoLevel() { + let dial = WatchDial(plan: plan(), prices: prices(days: 1), nowMs: at(14, 12)) + let tomorrow = dial.grid.index(atMs: at(15, 3))! + #expect(dial.quarters[tomorrow].minor == nil && dial.quarters[tomorrow].level == nil) + #expect(dial.price(tomorrow) == "—") + #expect(dial.what(tomorrow).contains("no price yet")) + } + + @Test func theCrownTapsAtNowTheCheapestEdgesAndTheCarsGoal() { + let dial = WatchDial(plan: plan(), prices: prices(), nowMs: at(14, 12)) + let cheap = try! #require(dial.cheapest) + #expect(dial.grid.startMs(cheap.lowerBound) == at(15, 2)) + #expect(dial.grid.startMs(cheap.upperBound) == at(15, 3, 45)) + #expect(dial.landmarks == [0, cheap.lowerBound, cheap.upperBound, dial.grid.index(atMs: at(15, 4, 30))!]) + } + + @Test func aFlatDaySitsInTheMiddle() { + #expect(WatchDial.level(50, lo: 50, hi: 50) == 3) + #expect(WatchDial.level(50, lo: 0, hi: 100) == 4) + #expect(WatchDial.level(100, lo: 0, hi: 100) == 7) + } + + // MARK: The car, cheap power and the complication + + @Test func theCarSaysWhatTheBoxLastConfirmedAndWhen() { + let t = at(14, 12, 30) + let car = CarEntry.Car(loadpointID: "lp1", pluggedIn: true, socPct: 42, socSource: "assumed", socReadAtMs: t, powerW: 0, targetSocPct: 80, holding: false) + let record = CacheRecord(siteId: "s1", carrier: .relay, srcState: .live, readAtMs: t, value: CarEntry(cars: [car])) + let glance = WatchGlance.cars(record, plan: plan(), nowMs: t, zone: TimeZone(identifier: Self.zone)!)[0] + #expect(glance.cells == 4) + #expect(glance.levelText == "42\u{00A0}% assumed") + #expect(glance.state == "Plugged in") + #expect(glance.goal == "Goal 80\u{00A0}%") + #expect(glance.next == "Charges 01:00–04:45") + #expect(glance.asOf == "AS OF 12:30") + var holding = record + holding.value.cars[0].holding = true + #expect(WatchGlance.cars(holding, plan: nil, nowMs: t, zone: .current)[0].state == "Charge now on") + } + + @Test func cheapPowerIsEachDaysCheapestTwoHoursLeft() { + // Today's 02–04 is over by noon: what is left of today is cheapest + // 12–14, well under the evening. Tomorrow's 02–04 stands whole. + let windows = WatchGlance.cheapWindows(prices(), nowMs: at(14, 12)) + #expect(windows.map(\.startMs) == [at(14, 12), at(15, 2)]) + #expect(windows[1].range == "02:00–04:00") + #expect(windows[1].price == "30.0 öre") + #expect(windows[0].price == "70.0 öre") + // After the evening peak nothing stands out from what is left. + #expect(WatchGlance.cheapWindows(prices(days: 1), nowMs: at(14, 21)).isEmpty) + // A flat day has nothing to point at. + var flat = prices() + flat.prices.slots = flat.prices.slots.map { var s = $0; s.totalMinor = 70; return s } + #expect(WatchGlance.cheapWindows(flat, nowMs: at(14, 1)).isEmpty) + } + + @Test func tomorrowsPricesWakeTheComplicationOnce() { + let t = at(14, 13, 5) + #expect(PriceEntry.bringsNewDay(old: prices(days: 1), new: prices(days: 2), nowMs: t)) + #expect(!PriceEntry.bringsNewDay(old: prices(days: 2), new: prices(days: 2), nowMs: t)) + #expect(!PriceEntry.bringsNewDay(old: nil, new: prices(days: 1), nowMs: t)) + #expect(PriceEntry.bringsNewDay(old: nil, new: prices(days: 2), nowMs: t)) + } + + @Test func theComplicationStepsByTheQuarterAndDropsAnOldLevel() { + let t = at(14, 12, 7) + let plan = CacheRead(record: CacheRecord(siteId: "s1", carrier: .relay, srcState: .live, readAtMs: t, value: plan()), origin: .app) + let price = CacheRead(record: CacheRecord(siteId: "s1", carrier: .relay, srcState: .live, readAtMs: t, value: prices()), origin: .app) + let now = CacheRead(record: now(readAtMs: t), origin: .app) + let faces = WatchGlance.faces(plan: plan, prices: price, now: now, nowMs: t) + // Now, then every quarter for a day. + #expect(faces.count == 97) + #expect(faces[1].atMs == at(14, 12, 15)) + #expect(faces[0].batteryPct == 64) + #expect(faces.first { $0.atMs - t > WatchGlance.levelForMs }?.batteryPct == nil) + #expect(faces.allSatisfy { $0.dial != nil }) + } + + // MARK: Words + + @Test func everyWatchWordHasSwedish() throws { + let url = try #require(Words.bundle.url(forResource: "Watch", withExtension: "strings", subdirectory: nil, localization: "sv")) + let sv = try #require(NSDictionary(contentsOf: url) as? [String: String]) + for word in WatchWord.allCases { + let swedish = try #require(sv[word.rawValue], "no Swedish for \"\(word.rawValue)\"") + #expect(!swedish.isEmpty) + #expect(word.rawValue.components(separatedBy: "%@").count == swedish.components(separatedBy: "%@").count, "\(word.rawValue)") + } + } +} diff --git a/appleApp/FTWWatch-Info.plist b/appleApp/FTWWatch-Info.plist new file mode 100644 index 0000000..03bab63 --- /dev/null +++ b/appleApp/FTWWatch-Info.plist @@ -0,0 +1,13 @@ + + + + + FTWAppGroup + $(FTW_APP_GROUP) + CFBundleLocalizations + + en + sv + + + diff --git a/appleApp/FTWWatch.entitlements b/appleApp/FTWWatch.entitlements new file mode 100644 index 0000000..04c6e8a --- /dev/null +++ b/appleApp/FTWWatch.entitlements @@ -0,0 +1,10 @@ + + + + + com.apple.security.application-groups + + $(FTW_APP_GROUP) + + + diff --git a/appleApp/FTWWatch/AppIcon.icon/Assets/bolt.svg b/appleApp/FTWWatch/AppIcon.icon/Assets/bolt.svg new file mode 100644 index 0000000..e2ef068 --- /dev/null +++ b/appleApp/FTWWatch/AppIcon.icon/Assets/bolt.svg @@ -0,0 +1,4 @@ + + + + diff --git a/appleApp/FTWWatch/AppIcon.icon/icon.json b/appleApp/FTWWatch/AppIcon.icon/icon.json new file mode 100644 index 0000000..d0f7142 --- /dev/null +++ b/appleApp/FTWWatch/AppIcon.icon/icon.json @@ -0,0 +1,45 @@ +{ + "fill-specializations" : [ + { + "value" : { + "solid" : "display-p3:0.01961,0.03137,0.05882,1.00000" + } + }, + { + "appearance" : "dark", + "value" : { + "solid" : "display-p3:0.01961,0.03137,0.05882,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/FTWWatch/CarPage.swift b/appleApp/FTWWatch/CarPage.swift new file mode 100644 index 0000000..8f532e1 --- /dev/null +++ b/appleApp/FTWWatch/CarPage.swift @@ -0,0 +1,114 @@ +import FTWShared +import SwiftUI + +/// The car as the box last described it, dated, and the three commands: +/// Charge now, Pause and Back to the plan. Each asks once more, goes +/// through the phone, and shows the box's receipt under the buttons. +struct CarPage: View { + let model: WatchModel + @State private var asking: Ask? + + struct Ask: Identifiable { + let op: WatchCommand.Op + let question: String + let action: String + var id: String { action } + } + + var body: some View { + TimelineView(.everyMinute) { timeline in + let nowMs = timeline.date.timeIntervalSince1970 * 1000 + let cars = WatchGlance.cars(model.context?.cars, plan: model.context?.plan?.value, nowMs: nowMs, zone: model.zone) + ScrollView { + VStack(alignment: .leading, spacing: 8) { + if model.context?.cars == nil { + // Nothing read yet is not "no charger": the shape of + // the page until the phone says. + CarBlock(car: .shape, model: model, ask: { _ in }) + .redacted(reason: .placeholder) + .disabled(true) + } else if cars.isEmpty { + Text(WatchWord.noCharger.text) + .font(.footnote) + .foregroundStyle(Theme.ink2) + } + ForEach(cars) { car in + CarBlock(car: car, model: model, ask: { asking = $0 }) + } + } + .frame(maxWidth: .infinity, alignment: .leading) + } + } + .navigationTitle(WatchWord.car.text) + .sheet(item: $asking) { a in + Confirm(question: a.question, action: a.action) { model.send(a.op) } + } + } +} + +private struct CarBlock: View { + let car: WatchGlance.Car + let model: WatchModel + let ask: (CarPage.Ask) -> Void + + var body: some View { + VStack(alignment: .leading, spacing: 6) { + HStack(alignment: .firstTextBaseline, spacing: 6) { + Text(car.levelText ?? "—") + .font(Theme.figure(26)) + .foregroundStyle(Theme.ink) + .minimumScaleFactor(0.7) + .lineLimit(1) + Spacer(minLength: 0) + if let cells = car.cells { + Meter(cells: cells, hue: Theme.car) + } + } + Text(car.state) + .font(.footnote.weight(.semibold)) + .foregroundStyle(Theme.ink) + if let line = [car.goal, car.next].compactMap({ $0 }).nilIfEmpty?.joined(separator: " · ") { + Text(line) + .font(.caption2) + .foregroundStyle(Theme.ink2) + } + Text(car.asOf) + .font(Theme.mono(.caption2)) + .foregroundStyle(Theme.ink3) + + // Room for the receipt, above the buttons, so nothing moves + // when it lands and it shows where the tap was. + ZStack(alignment: .topLeading) { + ReceiptLine(receipt: WatchReceipt(commandID: "", op: .mode(""), sent: .waiting, accepted: .waiting, confirmed: .waiting, text: "\n", tone: .working)) + .hidden() + .accessibilityHidden(true) + if let r = model.receipt(for: car.loadpointID) { + ReceiptLine(receipt: r) + } else if !model.canSend { + Text(WatchWord.phoneUnreachable.text) + .font(.footnote) + .foregroundStyle(Theme.ink2) + } + } + let sending = model.sending(car.loadpointID) + let off = !model.canSend || sending + VStack(spacing: 4) { + command(WatchWord.chargeNow.text, .chargeNow(loadpointID: car.loadpointID), WatchWord.chargeNowAsk.text, off: off) + .accessibilityIdentifier("watch.car.chargeNow") + command(WatchWord.pause.text, .pause(loadpointID: car.loadpointID), WatchWord.pauseAsk.text, off: off) + command(WatchWord.backToPlan.text, .backToPlan(loadpointID: car.loadpointID), WatchWord.backToPlanAsk.text, off: off) + } + .padding(.top, 2) + } + } + + private func command(_ title: String, _ op: WatchCommand.Op, _ question: String, off: Bool) -> some View { + Button(title) { ask(CarPage.Ask(op: op, question: question, action: title)) } + .buttonStyle(.bordered) + .disabled(off) + } +} + +extension Array { + var nilIfEmpty: [Element]? { isEmpty ? nil : self } +} diff --git a/appleApp/FTWWatch/FTWWatchApp.swift b/appleApp/FTWWatch/FTWWatchApp.swift new file mode 100644 index 0000000..da9aacc --- /dev/null +++ b/appleApp/FTWWatch/FTWWatchApp.swift @@ -0,0 +1,73 @@ +import FTWShared +import SwiftUI +import WatchConnectivity + +@main +struct FTWWatchApp: App { + @State private var model: WatchModel + @Environment(\.scenePhase) private var phase + + init() { + // The model starts before the first frame, so a context the phone + // left earlier paints at once. + let model = WatchModel.make() + model.start() + _model = State(initialValue: model) + } + + var body: some Scene { + WindowGroup { + WatchRoot(model: model) + .onChange(of: phase, initial: true) { _, phase in + // On screen: ask the phone for Now. Away: let it go, so + // the phone closes its session and frees a relay stream. + if phase == .active { model.look() } else { model.away() } + } + } + // The phone sent tomorrow's prices or a new context while the app + // slept. Stay up until WatchConnectivity has handed it all over. + .backgroundTask(.watchConnectivity) { + await ContentPending.drained() + } + } +} + +/// Waits until WatchConnectivity holds nothing more for this app, by +/// watching the session's own flag rather than asking again and again. +enum ContentPending { + static func drained() async { + let session = WCSession.default + if session.activationState != .activated { + session.activate() + } + guard session.hasContentPending else { return } + await withCheckedContinuation { (done: CheckedContinuation) in + let once = Once(done) + let watch = session.observe(\.hasContentPending, options: [.initial, .new]) { s, _ in + if !s.hasContentPending { once.fire() } + } + once.keep(watch) + } + } + + /// Resumes once, and holds the observation until it has. + private final class Once: @unchecked Sendable { + private var continuation: CheckedContinuation? + private var observation: NSKeyValueObservation? + private let lock = NSLock() + + init(_ c: CheckedContinuation) { continuation = c } + + func keep(_ o: NSKeyValueObservation) { + lock.withLock { if continuation == nil { o.invalidate() } else { observation = o } } + } + + func fire() { + let c: CheckedContinuation? = lock.withLock { + defer { continuation = nil; observation?.invalidate(); observation = nil } + return continuation + } + c?.resume() + } + } +} diff --git a/appleApp/FTWWatch/Paths/Paths.swift b/appleApp/FTWWatch/Paths/Paths.swift new file mode 100644 index 0000000..825c7dd --- /dev/null +++ b/appleApp/FTWWatch/Paths/Paths.swift @@ -0,0 +1,45 @@ +import FTWShared + +// Where the watch reads the home from, and where its commands go. Two +// seams, so the watch can stand on its own later without its screens +// knowing. Today one path does both: `PhonePath`, through the iPhone over +// WatchConnectivity. Later, with the phone away on LTE: +// +// - `CloudReadPath` reads Now, the plan and the prices from FTW Cloud over +// HTTPS, signed by the box. +// - `DirectPath` sends commands to the box through the relay's exchange +// (ADR 0009), with the watch's own key (`WatchIdentity`). +// +// Neither is built yet. The watch already makes its own `cmdId`, so a +// command tried both ways runs once. + +/// What a path tells the watch. +enum PathEvent: Sendable { + /// Everything the watch shows, replaced whole. + case context(WatchContext) + /// A live frame of Now, or a step of a command's receipt. + case news(WatchNews) + /// Whether commands can leave now. + case canSend(Bool) + /// A command never left: nothing reached the box. + case notSent(commandID: String) +} + +typealias PathSink = @MainActor (PathEvent) -> Void + +/// Where the home comes from. +@MainActor +protocol ReadPath: AnyObject { + func start(_ sink: @escaping PathSink) + /// The watch app is on screen: live frames are wanted. + func look() + /// It left the screen. + func away() +} + +/// Where commands go. The path sends intent; the box decides, and every +/// mark of the receipt comes back from the box's answers. +@MainActor +protocol CommandPath: AnyObject { + func send(_ command: WatchCommand) +} diff --git a/appleApp/FTWWatch/Paths/PhonePath.swift b/appleApp/FTWWatch/Paths/PhonePath.swift new file mode 100644 index 0000000..9f12cf3 --- /dev/null +++ b/appleApp/FTWWatch/Paths/PhonePath.swift @@ -0,0 +1,100 @@ +import FTWShared +import os +import WatchConnectivity + +/// Both paths through the iPhone, over WatchConnectivity. The phone hands +/// over its records after each write of its cache, sends Now's frames +/// while the watch looks, and runs each command over its own session with +/// the box. watchOS gives an ordinary app no WebSocket (TN3135), so this is +/// the only way to the box until the relay's exchange exists. +@MainActor +final class PhonePath: NSObject, ReadPath, CommandPath { + nonisolated static let log = Logger(subsystem: "energy.ftw.app.watchkitapp", category: "phone") + + private var sink: PathSink? + private var looking = false + private var session: WCSession? { WCSession.isSupported() ? WCSession.default : nil } + + func start(_ sink: @escaping PathSink) { + self.sink = sink + guard let session else { return sink(.canSend(false)) } + session.delegate = self + session.activate() + } + + func look() { + looking = true + ask(.look) + } + + func away() { + looking = false + ask(.away) + } + + private func ask(_ a: WatchAsk) { + guard let session, session.activationState == .activated, session.isReachable, let data = try? a.data() else { return } + // WatchConnectivity calls back on its own queue, never the main one. + session.sendMessageData(data, replyHandler: nil) { @Sendable error in + PhonePath.log.notice("ask not delivered: \(error.localizedDescription, privacy: .public)") + } + } + + /// The phone answers at once that it has the command; the box's steps + /// follow as news. A send that fails never reached the box. + func send(_ command: WatchCommand) { + guard let session, session.activationState == .activated, session.isReachable, let data = try? WatchAsk.command(command).data() else { + sink?(.notSent(commandID: command.id)) + return + } + session.sendMessageData(data, replyHandler: { @Sendable [weak self] reply in + guard let news = WatchNews.from(reply) else { return } + Task { @MainActor in self?.sink?(.news(news)) } + }, errorHandler: { @Sendable [weak self] error in + PhonePath.log.notice("command not delivered: \(error.localizedDescription, privacy: .public)") + Task { @MainActor in self?.sink?(.notSent(commandID: command.id)) } + }) + } + + private func deliver(_ event: PathEvent) { + sink?(event) + } +} + +extension PhonePath: WCSessionDelegate { + nonisolated func session(_ session: WCSession, activationDidCompleteWith activationState: WCSessionActivationState, error: Error?) { + if let error { PhonePath.log.error("session: \(error.localizedDescription, privacy: .public)") } + // The phone's newest context, if it sent one while this app slept. + let data = session.receivedApplicationContext[WatchContext.key] as? Data + let reachable = session.isReachable + Task { @MainActor in + self.deliver(.canSend(reachable)) + if let data, let ctx = WatchContext.unpacked(data) { self.deliver(.context(ctx)) } + if self.looking { self.ask(.look) } + } + } + + nonisolated func sessionReachabilityDidChange(_ session: WCSession) { + let reachable = session.isReachable + Task { @MainActor in + self.deliver(.canSend(reachable)) + if reachable, self.looking { self.ask(.look) } + } + } + + nonisolated func session(_ session: WCSession, didReceiveApplicationContext applicationContext: [String: Any]) { + guard let data = applicationContext[WatchContext.key] as? Data, let ctx = WatchContext.unpacked(data) else { return } + Task { @MainActor in self.deliver(.context(ctx)) } + } + + /// Tomorrow's prices for the complication, sent while the app slept. + nonisolated func session(_ session: WCSession, didReceiveUserInfo userInfo: [String: Any] = [:]) { + guard let data = userInfo[WatchContext.key] as? Data, let ctx = WatchContext.unpacked(data) else { return } + Task { @MainActor in self.deliver(.context(ctx)) } + } + + nonisolated func session(_ session: WCSession, didReceiveMessageData messageData: Data) { + guard let news = WatchNews.from(messageData) else { return } + Task { @MainActor in self.deliver(.news(news)) } + } +} diff --git a/appleApp/FTWWatch/Phosphor.swift b/appleApp/FTWWatch/Phosphor.swift new file mode 100644 index 0000000..d504c1e --- /dev/null +++ b/appleApp/FTWWatch/Phosphor.swift @@ -0,0 +1,128 @@ +import FTWShared +import SwiftUI + +// Phosphor on the wrist: light means live. Only a reading the phone just +// passed on live may light up; everything else is ink and holds still. +// Four of the phone's six TUI details travel here: the status line, the +// 10-cell meter, the receipt log and the lit dot. The block price bars +// live on the dial and the price page. + +/// The mono status line: `● LIVE · FTW IN CONTROL`, `○ AS OF 14:05`. +struct StatusLine: View { + let status: WatchGlance.Status + + var body: some View { + HStack(spacing: 4) { + LitDot(isLive: status.lit) + Text(status.text) + .font(Theme.mono(.caption2)) + .foregroundStyle(status.alarm ? Theme.alarm : Theme.ink2) + .lineLimit(1) + .minimumScaleFactor(0.7) + } + .accessibilityElement(children: .combine) + } +} + +/// The phosphor dot: lit lime while live, a hollow ink ring otherwise. It +/// ignites with the spring and decays like a CRT, never pulses. The glow +/// is stacked circles, not a blur, and takes `isLive`, so a cache cannot +/// glow. +struct LitDot: View { + let isLive: Bool + @Environment(\.accessibilityReduceMotion) private var reduceMotion + + var body: some View { + ZStack { + Circle().fill(Theme.live.opacity(isLive ? 0.18 : 0)).frame(width: 11, height: 11) + Circle().fill(Theme.live.opacity(isLive ? 0.35 : 0)).frame(width: 8, height: 8) + Circle().fill(isLive ? Theme.live : .clear).frame(width: 5.5, height: 5.5) + Circle().stroke(Theme.ink3, lineWidth: 1).frame(width: 5.5, height: 5.5).opacity(isLive ? 0 : 1) + } + .frame(width: 11, height: 11) + .animation(reduceMotion ? nil : (isLive ? Theme.spring : .easeOut(duration: 0.4)), value: isLive) + } +} + +/// Level in tenths, readable at a glance: ten cells, the full ones in the +/// kind's hue. +struct Meter: View { + let cells: Int + let hue: Color + + var body: some View { + HStack(spacing: 2) { + ForEach(0..<10, id: \.self) { i in + RoundedRectangle(cornerRadius: 1) + .fill(i < cells ? hue : Theme.ink3.opacity(0.35)) + .frame(width: 5, height: 10) + } + } + .accessibilityHidden(true) + } +} + +/// The receipt log: `SENT ▸ ACCEPTED ▸ CONFIRMED` and one sentence. A mark +/// is ink once done, ink2 while under way, ink3 while it waits and alarm +/// when it failed. CONFIRMED turns lime only on the box's confirmation. +struct ReceiptLine: View { + let receipt: WatchReceipt + + var body: some View { + VStack(alignment: .leading, spacing: 3) { + Text("\(mark(WatchWord.sent.text, receipt.sent, confirmed: false))\(arrow)\(mark(WatchWord.accepted.text, receipt.accepted, confirmed: false))\(arrow)\(mark(WatchWord.confirmed.text, receipt.confirmed, confirmed: true))") + .font(Theme.mono(.caption2, weight: .semibold)) + .lineLimit(1) + .minimumScaleFactor(0.6) + Text(receipt.text) + .font(.footnote) + .foregroundStyle(tone) + .fixedSize(horizontal: false, vertical: true) + .contentTransition(.opacity) + } + .animation(Theme.spring, value: receipt) + .accessibilityElement(children: .combine) + .accessibilityIdentifier("watch.receipt") + } + + private var arrow: Text { Text(verbatim: " ▸ ").foregroundStyle(Theme.ink3) } + + private func mark(_ label: String, _ m: WatchReceipt.Mark, confirmed: Bool) -> Text { + let color: Color + switch m { + case .done: color = confirmed ? Theme.live : Theme.ink + case .active: color = Theme.ink2 + case .waiting: color = Theme.ink3 + case .failed: color = Theme.alarm + } + return Text(verbatim: m == .failed ? "✕ \(label)" : label).foregroundStyle(color) + } + + private var tone: Color { + switch receipt.tone { + case .working: return Theme.ink2 + case .done: return Theme.live + case .warning, .failed: return Theme.alarm + } + } +} + +/// Each energy kind's one hue. The battery has two: it stores in cyan and +/// gives in gold. The home is ink. +enum Hue { + static func of(_ node: WatchNow.Node, way: WatchNow.Way) -> Color { + switch node { + case .solar: return Theme.solar + case .battery: return way == .giving ? Theme.gives : Theme.stores + case .home: return Theme.ink + case .grid: return Theme.grid + case .car: return Theme.car + } + } +} + +extension Theme { + /// The phone's one spring (`FTW/UI/Theme.swift`), so the watch moves + /// as the app does. + static let spring = Animation.spring(duration: 0.35, bounce: 0.15) +} diff --git a/appleApp/FTWWatch/PlanPage.swift b/appleApp/FTWWatch/PlanPage.swift new file mode 100644 index 0000000..101e8a4 --- /dev/null +++ b/appleApp/FTWWatch/PlanPage.swift @@ -0,0 +1,122 @@ +import FTWShared +import SwiftUI + +/// The next 24 hours on the dial. The Crown steps the hand a quarter hour +/// at a time with a tick for each, and a light tap at now, at the edges of +/// the cheapest hours and at the car's goal. The middle shows that +/// quarter's time and price, and the line under the dial says what is +/// planned. A tap on the dial brings the hand back to now. +struct PlanPage: View { + let model: WatchModel + @State private var quarter = 0 + @State private var scrub = ScrubStep() + @State private var faces = DialMemo() + @FocusState private var focused: Bool + + var body: some View { + // The hand and the words move by the minute, never by the second. + TimelineView(.everyMinute) { timeline in + let nowMs = timeline.date.timeIntervalSince1970 * 1000 + let ctx = model.context + let dial = faces.dial(plan: ctx?.plan, prices: ctx?.prices, nowMs: nowMs) + let moment = PlanGlance.moment(ctx?.plan.map { CacheRead(record: $0, origin: .app) }, atMs: nowMs) + let i = min(max(quarter, 0), WatchDial.count - 1) + GeometryReader { geo in + // The dial takes what the two lines under it leave. + let side = max(80, min(geo.size.width - 4, geo.size.height - 30)) + VStack(spacing: 2) { + ZStack { + DialFace(dial: dial, selected: i, grey: moment.isGrey) + VStack(spacing: 0) { + Text(dial.time(i)) + .font(Theme.figure(side * 0.15)) + .foregroundStyle(Theme.ink) + Text(dial.price(i).uppercased()) + .font(.system(size: max(8, side * 0.062), weight: .medium, design: .monospaced)) + .foregroundStyle(Theme.ink2) + } + .accessibilityHidden(true) + } + .frame(width: side, height: side) + .contentShape(Circle()) + .onTapGesture { + quarter = 0 + focused = true + } + Text(i == 0 ? moment.summary : dial.what(i)) + .font(.system(size: 12)) + .foregroundStyle(i == 0 && moment.isGrey ? Theme.ink3 : Theme.ink2) + .multilineTextAlignment(.center) + .lineLimit(2, reservesSpace: true) + .minimumScaleFactor(0.85) + if let age = planAge(ctx?.plan, moment: moment) { + Text(age) + .font(.system(size: 10, weight: .medium, design: .monospaced)) + .foregroundStyle(Theme.ink3) + } + } + .frame(maxWidth: .infinity) + } + .focusable() + .focused($focused) + .digitalCrownRotation(detent: $quarter, from: 0, through: WatchDial.count - 1, by: 1, sensitivity: .high, isContinuous: false, isHapticFeedbackEnabled: false) + .onChange(of: quarter) { _, q in + scrub.move(to: q, landmark: dial.landmarks.contains(q)) + } + .accessibilityElement(children: .ignore) + .accessibilityLabel(WatchWord.planTitle.text) + .accessibilityValue("\(dial.time(i)), \(dial.price(i)), \(i == 0 ? moment.summary : dial.what(i))") + .accessibilityHint(WatchWord.turnTheCrown.text) + .accessibilityAdjustableAction { direction in + switch direction { + case .increment: quarter = min(quarter + 1, WatchDial.count - 1) + case .decrement: quarter = max(quarter - 1, 0) + @unknown default: break + } + } + .accessibilityIdentifier("watch.plan.dial") + } + .sensoryFeedback(.selection, trigger: scrub.ticks) + .sensoryFeedback(.impact(weight: .light), trigger: scrub.bumps) + .navigationTitle(WatchWord.planTitle.text) + .task { + // Focus once the page is on screen, so the Crown turns the hand + // rather than the pages. + try? await Task.sleep(for: .milliseconds(150)) + focused = true + } + .onAppear { + #if DEBUG + // `-FTWWatchQuarter 12` opens with the hand moved, for screenshots. + let q = UserDefaults.standard.integer(forKey: "FTWWatchQuarter") + if q > 0 { quarter = q } + #endif + } + } + + /// "PLANNED 16:45", or "PLAN FROM 14:05" once it is old. + private func planAge(_ plan: CacheRecord?, moment: PlanGlance.Moment) -> String? { + guard let plan else { return nil } + let at = ZoneClock.of(moment.timeZone).label(plan.readAtMs) + return (moment.state == .old ? WatchWord.planFrom : WatchWord.planned).format(at) + } +} + +/// The dial worked out once per plan and minute, so a turn of the Crown +/// only reads it. +@Observable +@MainActor +final class DialMemo { + @ObservationIgnored private var key: String? + @ObservationIgnored private var kept: WatchDial? + + func dial(plan: CacheRecord?, prices: CacheRecord?, nowMs: Double) -> WatchDial { + let minute = (nowMs / 60_000).rounded(.down) + let key = "\(plan?.readAtMs ?? 0)|\(prices?.readAtMs ?? 0)|\(minute)" + if key == self.key, let kept { return kept } + let d = WatchDial(plan: plan?.value, prices: prices?.value, nowMs: nowMs) + self.key = key + kept = d + return d + } +} diff --git a/appleApp/FTWWatch/PricePage.swift b/appleApp/FTWWatch/PricePage.swift new file mode 100644 index 0000000..f61cd6c --- /dev/null +++ b/appleApp/FTWWatch/PricePage.swift @@ -0,0 +1,59 @@ +import FTWShared +import SwiftUI + +/// The price now, the next twelve hours as block bars in eight levels, and +/// when it is cheapest. Prices are a level, never a state: ink, stronger as +/// they get dearer, with no red and no green. +struct PricePage: View { + let model: WatchModel + + var body: some View { + TimelineView(.everyMinute) { timeline in + let nowMs = timeline.date.timeIntervalSince1970 * 1000 + let m = PriceGlance.moment(model.context?.prices.map { CacheRead(record: $0, origin: .app) }, atMs: nowMs) + ScrollView { + VStack(alignment: .leading, spacing: 5) { + Text(WatchWord.priceNow.text.uppercased()) + .font(Theme.mono(.caption2)) + .foregroundStyle(Theme.ink3) + if let price = m.priceText { + HStack(alignment: .firstTextBaseline, spacing: 3) { + Text(price) + .font(Theme.figure(34)) + .foregroundStyle(Theme.ink) + Text(m.unitText) + .font(.caption2.weight(.medium)) + .foregroundStyle(Theme.ink2) + } + Text(m.includedText) + .font(.caption2) + .foregroundStyle(Theme.ink3) + } else { + Text(m.emptyText ?? "—") + .font(.footnote) + .foregroundStyle(Theme.ink2) + } + if !m.curve.isEmpty { + BlockBars(bars: m.curve) + .frame(height: 30) + .padding(.vertical, 2) + .accessibilityHidden(true) + } + if let cheap = m.cheapestText { + Text(cheap) + .font(.footnote.weight(.semibold)) + .foregroundStyle(Theme.ink) + } + if let fetched = m.fetchedText { + Text(fetched.uppercased()) + .font(Theme.mono(.caption2)) + .foregroundStyle(Theme.ink3) + } + } + .frame(maxWidth: .infinity, alignment: .leading) + } + .accessibilityIdentifier("watch.price") + } + .navigationTitle(WatchWord.priceTitle.text) + } +} diff --git a/appleApp/FTWWatch/PrivacyInfo.xcprivacy b/appleApp/FTWWatch/PrivacyInfo.xcprivacy new file mode 100644 index 0000000..dba570d --- /dev/null +++ b/appleApp/FTWWatch/PrivacyInfo.xcprivacy @@ -0,0 +1,31 @@ + + + + + NSPrivacyTracking + + NSPrivacyTrackingDomains + + NSPrivacyCollectedDataTypes + + NSPrivacyAccessedAPITypes + + + NSPrivacyAccessedAPIType + NSPrivacyAccessedAPICategoryFileTimestamp + NSPrivacyAccessedAPITypeReasons + + C617.1 + + + + NSPrivacyAccessedAPIType + NSPrivacyAccessedAPICategoryUserDefaults + NSPrivacyAccessedAPITypeReasons + + CA92.1 + + + + + diff --git a/appleApp/FTWWatch/WatchCache.swift b/appleApp/FTWWatch/WatchCache.swift new file mode 100644 index 0000000..43d6f85 --- /dev/null +++ b/appleApp/FTWWatch/WatchCache.swift @@ -0,0 +1,48 @@ +import Foundation +import FTWShared +import WidgetKit + +/// The watch's own copy of the last truth: the whole context it last had, +/// so it opens on it with no phone near, and the records its +/// complications read. Kept in this watch's App Group, which stays on the +/// watch. Each record keeps the time it was read, so what opens from here +/// is dated and never glows. +struct WatchCache: Sendable { + let files: AppGroupFiles + + static func shared() -> WatchCache? { AppGroupFiles.shared().map(WatchCache.init) } + + private var url: URL { files.directory.appendingPathComponent("watch-context") } + + func load() -> WatchContext? { + guard let data = try? Data(contentsOf: url) else { return nil } + return WatchContext.unpacked(data) + } + + /// Keep `ctx`. With no home, forget everything: the phone signed out. + func save(_ ctx: WatchContext) async { + guard ctx.hasHome else { + files.removeAll() + reload() + return + } + let asked = files.clearCount + do { + if let r = ctx.prices { _ = try await files.write(r, asked: asked) } + if let r = ctx.plan { _ = try await files.write(r, asked: asked) } + if let r = ctx.cars { _ = try await files.write(r, asked: asked) } + if let r = ctx.now { _ = try await files.write(r, asked: asked) } + guard files.clearCount == asked else { return } + try FileManager.default.createDirectory(at: files.directory, withIntermediateDirectories: true) + try ctx.packed().write(to: url, options: [.atomic, .completeFileProtectionUntilFirstUserAuthentication]) + reload() + } catch { + WatchModel.log.error("could not keep the last context: \(error.localizedDescription, privacy: .public)") + } + } + + private func reload() { + WidgetCenter.shared.reloadAllTimelines() + WidgetCenter.shared.invalidateRelevance(ofKind: WatchKinds.cheap) + } +} diff --git a/appleApp/FTWWatch/WatchIdentity.swift b/appleApp/FTWWatch/WatchIdentity.swift new file mode 100644 index 0000000..ff30e30 --- /dev/null +++ b/appleApp/FTWWatch/WatchIdentity.swift @@ -0,0 +1,52 @@ +import CryptoKit +import Foundation +import Security + +/// The watch's own key: an X25519 static key for Noise IK, made once on +/// this watch and kept in its Keychain. It never leaves the watch. In W1 +/// nothing uses it yet; when the relay's exchange exists (ADR 0009), the +/// phone adds this watch to the home's roster by its public half, and the +/// watch reaches the box with it, phone or not. +/// +/// The Keychain opens it only while the watch has a passcode, so a key +/// that works means a watch unlocked on a wrist, and taking the passcode +/// off deletes it. +enum WatchIdentity { + static let service = "energy.ftw.app.watch" + static let account = "noise-static" + + /// The public half, made on first use. Nil when this watch has no + /// passcode, so the key cannot be kept. + static func publicKey() -> Data? { + if let raw = read() { + return (try? Curve25519.KeyAgreement.PrivateKey(rawRepresentation: raw))?.publicKey.rawRepresentation + } + let key = Curve25519.KeyAgreement.PrivateKey() + guard store(key.rawRepresentation) else { return nil } + return key.publicKey.rawRepresentation + } + + private static var query: [String: Any] { + [kSecClass as String: kSecClassGenericPassword, kSecAttrService as String: service, kSecAttrAccount as String: account] + } + + private static func read() -> Data? { + var q = query + q[kSecReturnData as String] = true + q[kSecMatchLimit as String] = kSecMatchLimitOne + var out: CFTypeRef? + guard SecItemCopyMatching(q as CFDictionary, &out) == errSecSuccess else { return nil } + return out as? Data + } + + private static func store(_ raw: Data) -> Bool { + var q = query + q[kSecValueData as String] = raw + q[kSecAttrAccessible as String] = kSecAttrAccessibleWhenPasscodeSetThisDeviceOnly + let status = SecItemAdd(q as CFDictionary, nil) + if status != errSecSuccess { + WatchModel.log.notice("watch key not kept: \(status, privacy: .public); a watch without a passcode has no key of its own") + } + return status == errSecSuccess + } +} diff --git a/appleApp/FTWWatch/WatchModel.swift b/appleApp/FTWWatch/WatchModel.swift new file mode 100644 index 0000000..a8ccc67 --- /dev/null +++ b/appleApp/FTWWatch/WatchModel.swift @@ -0,0 +1,168 @@ +import Foundation +import FTWShared +import Observation +import os + +/// The watch's side of FTW: the last truth it kept, Now as it streams while +/// the app is on screen, and the receipts of its commands. +/// +/// It reads through a `ReadPath` and sends through a `CommandPath`; today +/// both are the iPhone. It opens on its own cache before any path speaks, +/// so a watch with no phone near still shows what it last knew, dated. A +/// reading glows only for a moment after a live frame arrived. +@Observable +@MainActor +final class WatchModel { + nonisolated static let log = Logger(subsystem: "energy.ftw.app.watchkitapp", category: "watch") + + /// The last context: from a path, or kept from before. Nil before the + /// watch ever had one. + private(set) var context: WatchContext? + /// Now: the latest live frame, or the context's copy. + private(set) var now: CacheRecord? + /// Whether Now glows. Off `WatchGlance.liveForMs` after the last frame. + private(set) var lit = false + /// Commands can leave now. + private(set) var canSend = false + /// The receipt of the last command for each target: a charger's id, or + /// `modeTarget`. + private(set) var receipts: [String: WatchReceipt] = [:] + + static let modeTarget = "mode" + + @ObservationIgnored private let read: any ReadPath + @ObservationIgnored private let command: any CommandPath + @ObservationIgnored private let cache: WatchCache? + /// When the latest live frame reached this watch. Nil for a copy from + /// a context or the cache, which never glows. + @ObservationIgnored private var nowArrivedAtMs: Double? + @ObservationIgnored private var cooling: Task? + + private var nowMs: Double { Date.now.timeIntervalSince1970 * 1000 } + + var zone: TimeZone { WatchGlance.zone(context) } + var hasHome: Bool { context?.hasHome == true } + + init(read: any ReadPath, command: any CommandPath, cache: WatchCache?) { + self.read = read + self.command = command + self.cache = cache + } + + /// The paths this build uses. Today the phone carries both. + static func make() -> WatchModel { + let phone = PhonePath() + return WatchModel(read: phone, command: phone, cache: .shared()) + } + + /// Open on the last truth, then listen. + func start() { + if let kept = cache?.load() { take(kept, keep: false) } + read.start { [weak self] in self?.handle($0) } + // The watch's own key, made once, for the day it reaches the box + // without the phone. + Task.detached(priority: .utility) { + if WatchIdentity.publicKey() == nil { WatchModel.log.notice("no watch key yet") } + } + } + + func look() { read.look() } + + /// Off screen: keep the newest frame as the last truth, dated. + func away() { + read.away() + if var ctx = context, let now, now.readAtMs > (ctx.now?.readAtMs ?? 0) { + ctx.now = now + context = ctx + keep(ctx) + } + } + + private func handle(_ event: PathEvent) { + switch event { + case .context(let ctx): take(ctx, keep: true) + case .news(.now(let record)): frame(record) + case .news(.receipt(let r)): receipt(r) + case .canSend(let ok): canSend = ok + case .notSent(let id): + guard let hit = receipts.first(where: { $0.value.commandID == id }), hit.value.sent == .active else { return } + receipts[hit.key] = .unreachable(WatchCommand(id: id, op: hit.value.op, home: context?.home ?? "", sentAtMs: nowMs)) + } + } + + // MARK: The context + + private func take(_ ctx: WatchContext, keep: Bool) { + if let old = context, old.sameAs(ctx) { return } + context = ctx + // A live frame newer than the context's copy keeps its place. + if nowArrivedAtMs == nil || (ctx.now?.readAtMs ?? 0) > (now?.readAtMs ?? 0) { + now = ctx.now + nowArrivedAtMs = nil + relight() + } + if let r = ctx.receipt { receipt(r) } + if keep { self.keep(ctx) } + } + + private func keep(_ ctx: WatchContext) { + guard let cache else { return } + Task { await cache.save(ctx) } + } + + private func frame(_ record: CacheRecord) { + guard context == nil || record.siteId == context?.home else { return } + now = record + nowArrivedAtMs = nowMs + relight() + } + + /// Glow while the last frame is fresh, then cool once: one wake per + /// frame, no ticking timer. + private func relight() { + lit = WatchGlance.lit(now, arrivedAtMs: nowArrivedAtMs, nowMs: nowMs) + cooling?.cancel() + guard lit else { return } + cooling = Task { [weak self] in + try? await Task.sleep(for: .milliseconds(Int(WatchGlance.liveForMs) + 50)) + guard !Task.isCancelled else { return } + self?.relight() + } + } + + // MARK: Commands + + static func target(_ op: WatchCommand.Op) -> String { + switch op { + case .chargeNow(let id), .pause(let id), .backToPlan(let id): return id + case .mode: return modeTarget + } + } + + func receipt(for target: String) -> WatchReceipt? { receipts[target] } + + /// A command is on its way for this target. + func sending(_ target: String) -> Bool { receipts[target].map { !$0.settled } ?? false } + + /// The receipt shows at once; every mark after "sending" is the box's + /// answer as the path brings it back. + func send(_ op: WatchCommand.Op) { + let target = Self.target(op) + guard let home = context?.home, !sending(target) else { return } + let c = WatchCommand(op: op, home: home, sentAtMs: nowMs) + receipts[target] = .tapped(c) + command.send(c) + // One wait for an answer, not a loop: past it, say so honestly. + Task { [weak self] in + try? await Task.sleep(for: .milliseconds(Int(WatchReceipt.answerMs))) + guard let self, let r = self.receipts[target], r.commandID == c.id else { return } + self.receipts[target] = r.noAnswer() + } + } + + private func receipt(_ r: WatchReceipt) { + let target = Self.target(r.op) + guard let mine = receipts[target], let next = mine.taking(r) else { return } + receipts[target] = next + } +} diff --git a/appleApp/FTWWatch/WatchRoot.swift b/appleApp/FTWWatch/WatchRoot.swift new file mode 100644 index 0000000..3d4e517 --- /dev/null +++ b/appleApp/FTWWatch/WatchRoot.swift @@ -0,0 +1,217 @@ +import FTWShared +import SwiftUI + +/// Four pages, one idea each: Now, the plan, the car and the price. The +/// Crown moves between them, except on the plan, where it steps the hand. +struct WatchRoot: View { + let model: WatchModel + @State private var page: Page = WatchRoot.firstPage + + enum Page: String, Hashable { case now, plan, car, price } + + var body: some View { + NavigationStack { + if model.hasHome { + TabView(selection: $page) { + NowPage(model: model).tag(Page.now) + PlanPage(model: model).tag(Page.plan) + CarPage(model: model).tag(Page.car) + PricePage(model: model).tag(Page.price) + } + .tabViewStyle(.verticalPage) + } else { + NoHome(waiting: model.context == nil) + } + } + .containerBackground(Theme.ground, for: .navigation) + } + + /// `-FTWWatchPage plan` opens on a page, for screenshots. + static var firstPage: Page { + UserDefaults.standard.string(forKey: "FTWWatchPage").flatMap(Page.init(rawValue:)) ?? .now + } +} + +/// Before the phone has handed over a home, or after it signed out. +private struct NoHome: View { + let waiting: Bool + + var body: some View { + ScrollView { + VStack(alignment: .leading, spacing: 8) { + Text(WatchWord.openOnPhone.text) + .font(.headline) + .foregroundStyle(Theme.ink) + Text(WatchWord.openOnPhoneDetail.text) + .font(.footnote) + .foregroundStyle(Theme.ink2) + } + .frame(maxWidth: .infinity, alignment: .leading) + } + .navigationTitle("FTW") + .accessibilityIdentifier("watch.nohome") + } +} + +/// Now in one glance: each node's figure, lit in its hue while the phone +/// streams it, ink with the time it was read otherwise, and how the home +/// is run. +struct NowPage: View { + let model: WatchModel + /// Always On redraws once a minute: a figure lit then would show an old + /// reading as live, so nothing glows while the wrist is down. + @Environment(\.isLuminanceReduced) private var dim + + var body: some View { + let lit = model.lit && !dim + let figures = WatchGlance.figures(model.now?.value, lit: lit) + ScrollView { + VStack(alignment: .leading, spacing: 6) { + StatusLine(status: WatchGlance.status(model.now, lit: lit, demo: model.context?.isDemo == true, zone: model.zone)) + .padding(.bottom, 2) + ForEach(figures) { f in + FigureRow(figure: f, way: model.now?.value.reading(f.node)?.way ?? .resting) + } + if let modes = model.context?.modes, let label = modes.label { + NavigationLink { + ModeList(model: model, modes: modes) + } label: { + HStack { + VStack(alignment: .leading, spacing: 1) { + Text(WatchWord.mode.text.uppercased()) + .font(Theme.mono(.caption2)) + .foregroundStyle(Theme.ink3) + Text(label) + .font(.footnote.weight(.semibold)) + .foregroundStyle(Theme.ink) + .lineLimit(2) + } + Spacer(minLength: 0) + if model.sending(WatchModel.modeTarget) { + ProgressView().frame(width: 16) + } + } + } + .padding(.top, 4) + .accessibilityIdentifier("watch.mode") + } + } + .frame(maxWidth: .infinity, alignment: .leading) + } + .navigationTitle(WatchWord.nowTitle.text) + } +} + +/// One node: its name and word on the left, the figure on the right. Lit, +/// the figure takes its kind's hue; otherwise it is ink and holds still. +private struct FigureRow: View { + let figure: WatchGlance.Figure + let way: WatchNow.Way + + var body: some View { + HStack(alignment: .firstTextBaseline, spacing: 4) { + VStack(alignment: .leading, spacing: 0) { + Text(figure.title) + .font(.caption2.weight(.semibold)) + .foregroundStyle(Theme.ink2) + .lineLimit(1) + Text(figure.level.map { "\(figure.word) · \($0)" } ?? figure.word) + .font(.caption2) + .foregroundStyle(Theme.ink3) + .lineLimit(1) + .minimumScaleFactor(0.8) + } + Spacer(minLength: 2) + HStack(alignment: .firstTextBaseline, spacing: 2) { + Text(figure.value) + .font(Theme.figure(20)) + .foregroundStyle(figure.lit ? Hue.of(figure.node, way: way) : Theme.ink) + .contentTransition(.numericText()) + Text(figure.unit) + .font(.caption2.weight(.medium)) + .foregroundStyle(Theme.ink2) + } + .animation(Theme.spring, value: figure.value) + } + .accessibilityElement(children: .combine) + .accessibilityIdentifier("watch.now.\(figure.node.rawValue)") + } +} + +/// The box's modes, as the phone last read them. A choice asks once more +/// and then goes through the phone; the receipt stays under the list. +struct ModeList: View { + let model: WatchModel + let modes: WatchModes + @State private var asking: WatchModes.Mode? + + var body: some View { + List { + Section { + ForEach(modes.modes) { m in + Button { + asking = m + } label: { + HStack { + Text(m.name).foregroundStyle(Theme.ink) + Spacer(minLength: 0) + if m.key == modes.shown { + Image(systemName: "checkmark").foregroundStyle(Theme.ink2) + } + } + } + .disabled(!modes.mayControl || !model.canSend || model.sending(WatchModel.modeTarget) || m.key == modes.shown) + .accessibilityIdentifier("watch.mode.\(m.key)") + } + } footer: { + VStack(alignment: .leading, spacing: 4) { + if !model.canSend { + Text(WatchWord.phoneUnreachable.text).foregroundStyle(Theme.ink2) + } else if !modes.mayControl { + Text(WatchWord.cannotChangeMode.text).foregroundStyle(Theme.ink2) + } + if let r = model.receipt(for: WatchModel.modeTarget) { + ReceiptLine(receipt: r) + } + } + .font(.footnote) + } + } + .navigationTitle(WatchWord.mode.text) + .sheet(item: $asking) { m in + Confirm(question: WatchWord.modeAsk.format(m.name), action: m.name) { + model.send(.mode(m.key)) + } + } + } +} + +/// One more question before a command, with the double tap on the yes. +struct Confirm: View { + let question: String + let action: String + let go: () -> Void + @Environment(\.dismiss) private var dismiss + + var body: some View { + ScrollView { + VStack(spacing: 10) { + Text(question) + .font(.headline) + .foregroundStyle(Theme.ink) + .multilineTextAlignment(.center) + .fixedSize(horizontal: false, vertical: true) + Button(action) { + go() + dismiss() + } + .buttonStyle(.borderedProminent) + .tint(Theme.ink) + .foregroundStyle(Theme.ground) + .handGestureShortcut(.primaryAction) + .accessibilityIdentifier("watch.confirm") + Button(WatchWord.cancel.text, role: .cancel) { dismiss() } + } + } + } +} diff --git a/appleApp/FTWWatchShared/BlockBars.swift b/appleApp/FTWWatchShared/BlockBars.swift new file mode 100644 index 0000000..4293987 --- /dev/null +++ b/appleApp/FTWWatchShared/BlockBars.swift @@ -0,0 +1,35 @@ +import FTWShared +import SwiftUI + +/// The hours ahead as the block-glyph scale (▁▂▃▄▅▆▇█): one bar per slot, +/// its height the price in eight levels. Ink, stronger for the slot that +/// holds now; never red and green, because a price is a level, not a +/// state. Two still paths, so a complication draws it as cheaply as the +/// app. +struct BlockBars: View { + let bars: [PriceStrip.Bar] + + var body: some View { + GeometryReader { geo in + ZStack { + path(geo.size, current: false).fill(Theme.ink.opacity(0.55)) + path(geo.size, current: true).fill(Theme.ink) + } + } + } + + private func path(_ size: CGSize, current: Bool) -> Path { + let values = bars.map(\.minor) + let lo = values.min() ?? 0, hi = values.max() ?? 0 + let gap: CGFloat = bars.count > 30 ? 0.5 : 1 + let w = (size.width - gap * CGFloat(max(bars.count - 1, 0))) / CGFloat(max(bars.count, 1)) + let step = size.height / CGFloat(WatchDial.levels) + var p = Path() + for (i, b) in bars.enumerated() where b.current == current { + let level = hi - lo < 0.5 ? WatchDial.levels / 2 - 1 : min(WatchDial.levels - 1, Int((b.minor - lo) / (hi - lo) * Double(WatchDial.levels))) + let h = step * CGFloat(level + 1) + p.addRect(CGRect(x: CGFloat(i) * (w + gap), y: size.height - h, width: max(0.6, w), height: h)) + } + return p + } +} diff --git a/appleApp/FTWWatchShared/DialFace.swift b/appleApp/FTWWatchShared/DialFace.swift new file mode 100644 index 0000000..367bda2 --- /dev/null +++ b/appleApp/FTWWatchShared/DialFace.swift @@ -0,0 +1,155 @@ +import FTWShared +import SwiftUI +import WidgetKit + +/// The plan dial's face, drawn from a `WatchDial`: price ticks coloured by +/// what the battery does, the car's dotted arc and the hand. The watch app +/// draws it large with its hours and the Crown's choice; the complication +/// draws the same face small. +/// +/// Every mark is a still path filled once. Nothing glows: a plan is intent, +/// not a reading. +struct DialFace: View { + enum Size { case app, complication } + + let dial: WatchDial + /// The quarter the Crown chose; nil or 0 points the hand at now. + var selected: Int? = nil + /// The plan is old or the box could not plan: every tick in ink3. + var grey = false + var size: Size = .app + + var body: some View { + GeometryReader { geo in + let side = min(geo.size.width, geo.size.height) + let g = DialGeometry(side: side, size: size) + ZStack { + if size == .app { + Circle() + .stroke(Theme.ink.opacity(0.18), lineWidth: 0.5) + .frame(width: g.rim * 2, height: g.rim * 2) + HourLabels(g: g, dial: dial) + } + ticks(g, .waits).fill(grey ? Theme.ink3.opacity(0.45) : Theme.ink3.opacity(0.6)) + ticks(g, .gives).fill(grey ? Theme.ink3.opacity(0.45) : Theme.ink) + ticks(g, .stores).fill(grey ? Theme.ink3.opacity(0.45) : Theme.stores) + .widgetAccentable() + stubs(g).fill(Theme.ink3.opacity(0.5)) + carDots(g).fill(grey ? Theme.ink3 : Theme.car) + .widgetAccentable() + if size == .app, let i = selected, i > 0 { + lockOn(g, i).fill(Theme.ink) + } + hand(g).stroke(Theme.ink, style: StrokeStyle(lineWidth: size == .app ? 1.5 : 1.2, lineCap: .round)) + } + .frame(width: geo.size.width, height: geo.size.height) + } + } + + // MARK: Paths + + /// One path per battery action, so each fills in one go. + private func ticks(_ g: DialGeometry, _ action: WatchDial.Battery) -> Path { + var p = Path() + for (i, q) in dial.quarters.enumerated() where q.battery == action { + guard let level = q.level else { continue } + let length = g.unit * Double(level + 1) + p.addPath(g.tick(at: dial.angle(quarter: i), from: g.inner, length: length, width: g.tickWidth * dial.fade(i).squareRoot())) + } + return p + } + + /// Quarters the market has not priced yet: a short stub, never a level. + private func stubs(_ g: DialGeometry) -> Path { + var p = Path() + for (i, q) in dial.quarters.enumerated() where q.minor == nil && i % 2 == 0 { + p.addPath(g.tick(at: dial.angle(quarter: i), from: g.inner, length: g.unit * 0.6, width: g.tickWidth * 0.8)) + } + return p + } + + /// The car's charging: a dotted arc inside the ticks, a dot every + /// other quarter. + private func carDots(_ g: DialGeometry) -> Path { + var p = Path() + for (i, q) in dial.quarters.enumerated() where q.car && i % 2 == 0 { + let c = g.point(dial.angle(quarter: i), g.carRadius) + p.addEllipse(in: CGRect(x: c.x - g.dot / 2, y: c.y - g.dot / 2, width: g.dot, height: g.dot)) + } + return p + } + + /// The quarter the Crown chose: its tick at full length, in ink, and the + /// phone's lock-on brackets (`LockOn`) closing on its outer end. + private func lockOn(_ g: DialGeometry, _ i: Int) -> Path { + let a = dial.angle(quarter: i) + var p = g.tick(at: a, from: g.inner - g.unit * 0.8, length: g.rim - g.inner + g.unit * 0.8, width: g.tickWidth * 1.6) + p.addPath(LockOn.brackets(around: g.point(a, g.rim - g.unit), half: g.unit * 1.6, arm: g.unit * 0.8) + .strokedPath(StrokeStyle(lineWidth: 1, lineCap: .round, lineJoin: .round))) + return p + } + + private func hand(_ g: DialGeometry) -> Path { + let a = dial.handAngle(selected: selected) + var p = Path() + p.move(to: g.point(a, g.inner - g.unit * 1.2)) + p.addLine(to: g.point(a, g.rim + (size == .app ? 3 : 1))) + return p + } +} + +/// Where things sit on a dial `side` points across. +struct DialGeometry { + let side: CGFloat + let size: DialFace.Size + + var centre: CGPoint { CGPoint(x: side / 2, y: side / 2) } + /// The outer edge of the dearest tick. + var rim: CGFloat { side / 2 - (size == .app ? 11 : 1) } + /// The hours sit just outside the rim. + var labelRadius: CGFloat { side / 2 - 5 } + /// Where every tick starts. + var inner: CGFloat { rim * (size == .app ? 0.62 : 0.56) } + /// One price level's length. + var unit: CGFloat { (rim - inner) / CGFloat(WatchDial.levels) } + var tickWidth: CGFloat { max(0.9, side * (size == .app ? 0.0085 : 0.012)) } + var carRadius: CGFloat { inner - unit * (size == .app ? 1.6 : 1.4) } + var dot: CGFloat { max(1.4, side * 0.012) } + + /// A point `r` from the centre, `a` radians clockwise from the top. + func point(_ a: Double, _ r: CGFloat) -> CGPoint { + CGPoint(x: centre.x + r * CGFloat(sin(a)), y: centre.y - r * CGFloat(cos(a))) + } + + /// A tick as a thin quad along the radius. + func tick(at a: Double, from r0: CGFloat, length: CGFloat, width: CGFloat) -> Path { + let half = Double(width / 2) + let nx = CGFloat(cos(a)) * CGFloat(half), ny = CGFloat(sin(a)) * CGFloat(half) + let p0 = point(a, r0), p1 = point(a, r0 + length) + var p = Path() + p.move(to: CGPoint(x: p0.x - nx, y: p0.y - ny)) + p.addLine(to: CGPoint(x: p1.x - nx, y: p1.y - ny)) + p.addLine(to: CGPoint(x: p1.x + nx, y: p1.y + ny)) + p.addLine(to: CGPoint(x: p0.x + nx, y: p0.y + ny)) + p.closeSubpath() + return p + } +} + +/// 00, 06, 12 and 18 just outside the rim, in the terminal's mono. +private struct HourLabels: View { + let g: DialGeometry + let dial: WatchDial + + var body: some View { + ForEach([0, 6, 12, 18], id: \.self) { h in + // 00 at the bottom, 06 on the left, 12 at the top. + let a = Double(h) / 24 * 2 * .pi + .pi + let p = g.point(a, g.labelRadius) + Text(verbatim: h < 10 ? "0\(h)" : "\(h)") + .font(.system(size: 9, weight: .regular, design: .monospaced)) + .foregroundStyle(Theme.ink3) + .position(p) + } + } +} diff --git a/appleApp/FTWWatchShared/WatchKinds.swift b/appleApp/FTWWatchShared/WatchKinds.swift new file mode 100644 index 0000000..462b3ea --- /dev/null +++ b/appleApp/FTWWatchShared/WatchKinds.swift @@ -0,0 +1,6 @@ +/// The watch's widget kinds, named once for the app that reloads them and +/// the extension that draws them. +enum WatchKinds { + static let plan = "energy.ftw.watch.plan" + static let cheap = "energy.ftw.watch.cheap" +} diff --git a/appleApp/FTWWatchUITests/WatchTour.swift b/appleApp/FTWWatchUITests/WatchTour.swift new file mode 100644 index 0000000..8179743 --- /dev/null +++ b/appleApp/FTWWatchUITests/WatchTour.swift @@ -0,0 +1,98 @@ +import XCTest + +/// The watch app on a watch simulator paired with an iPhone simulator that +/// runs the demo (`-FTWDemo YES`). Everything comes through the phone, as +/// on a wrist: the records, Now's frames and the box's answers to a +/// command. For pictures and recordings, not a gate: without the phone +/// the watch shows its cache and says the phone is out of reach. +final class WatchTour: XCTestCase { + private func launch(_ page: String, _ extra: [String] = []) -> XCUIApplication { + let app = XCUIApplication() + app.launchArguments = ["-FTWWatchPage", page] + extra + app.launch() + return app + } + + private func shoot(_ name: String) { + let a = XCTAttachment(screenshot: XCUIScreen.main.screenshot()) + a.name = name + a.lifetime = .keepAlways + add(a) + } + + /// The Crown steps the hand a quarter at a time, forward through the + /// evening and back to now. + func testCrownScrub() throws { + let app = launch("plan") + XCTAssertTrue(app.otherElements["watch.plan.dial"].waitForExistence(timeout: 15)) + sleep(2) + for _ in 0..<36 { + XCUIDevice.shared.rotateDigitalCrown(delta: 0.06) + usleep(200_000) + } + sleep(1) + shoot("plan-scrubbed") + for _ in 0..<36 { + XCUIDevice.shared.rotateDigitalCrown(delta: -0.06) + usleep(120_000) + } + sleep(1) + } + + /// Charge now from the car page: the question, then the receipt as the + /// box answers through the phone. + func testChargeNowReceipt() throws { + let app = launch("car") + let button = app.buttons["watch.car.chargeNow"] + XCTAssertTrue(button.waitForExistence(timeout: 15)) + sleep(2) + button.tap() + let yes = app.buttons["watch.confirm"] + XCTAssertTrue(yes.waitForExistence(timeout: 5)) + sleep(1) + shoot("charge-now-ask") + yes.tap() + XCTAssertTrue(app.otherElements["watch.receipt"].waitForExistence(timeout: 10) || app.staticTexts["watch.receipt"].exists) + sleep(8) + shoot("charge-now-receipt") + } + + /// Now from top to bottom, the modes, and a change of mode with its + /// receipt, then back to the plan. + func testNowAndMode() throws { + let app = launch("now") + XCTAssertTrue(app.staticTexts.firstMatch.waitForExistence(timeout: 15)) + sleep(4) + shoot("now") + app.swipeUp() + sleep(1) + shoot("now-bottom") + let mode = app.buttons["watch.mode"] + XCTAssertTrue(mode.waitForExistence(timeout: 5)) + mode.tap() + sleep(2) + shoot("modes") + let idle = app.buttons["watch.mode.idle"] + if !idle.isHittable { app.swipeUp() } + XCTAssertTrue(idle.waitForExistence(timeout: 5)) + idle.tap() + let yes = app.buttons["watch.confirm"] + XCTAssertTrue(yes.waitForExistence(timeout: 5)) + shoot("mode-ask") + yes.tap() + sleep(6) + app.swipeUp() + sleep(1) + shoot("mode-receipt") + } + + func testPriceAndPlan() throws { + var app = launch("price") + sleep(5) + shoot("price") + app.terminate() + app = launch("plan") + sleep(5) + shoot("plan") + } +} diff --git a/appleApp/FTWWatchWidgets-Info.plist b/appleApp/FTWWatchWidgets-Info.plist new file mode 100644 index 0000000..f62f91d --- /dev/null +++ b/appleApp/FTWWatchWidgets-Info.plist @@ -0,0 +1,18 @@ + + + + + NSExtension + + NSExtensionPointIdentifier + com.apple.widgetkit-extension + + FTWAppGroup + $(FTW_APP_GROUP) + CFBundleLocalizations + + en + sv + + + diff --git a/appleApp/FTWWatchWidgets.entitlements b/appleApp/FTWWatchWidgets.entitlements new file mode 100644 index 0000000..04c6e8a --- /dev/null +++ b/appleApp/FTWWatchWidgets.entitlements @@ -0,0 +1,10 @@ + + + + + com.apple.security.application-groups + + $(FTW_APP_GROUP) + + + diff --git a/appleApp/FTWWatchWidgets/CheapPowerWidget.swift b/appleApp/FTWWatchWidgets/CheapPowerWidget.swift new file mode 100644 index 0000000..a569248 --- /dev/null +++ b/appleApp/FTWWatchWidgets/CheapPowerWidget.swift @@ -0,0 +1,97 @@ +import AppIntents +import FTWShared +import SwiftUI +import WidgetKit + +/// A card in the Smart Stack while power is at its cheapest: one for each +/// day's cheapest two hours that stand out, relevant from a quarter hour +/// before until they end. No place clue: that would need location access. +struct CheapPowerWidget: Widget { + var body: some WidgetConfiguration { + RelevanceConfiguration(kind: WatchKinds.cheap, provider: CheapProvider()) { entry in + CheapView(entry: entry) + } + .configurationDisplayName(WatchWord.cheapWidget.text) + .description(WatchWord.cheapWidgetSummary.text) + .supportedFamilies([.accessoryRectangular]) + } +} + +/// Which cheap window a card is for. Set by the provider, never by a +/// person. +struct CheapWindowIntent: WidgetConfigurationIntent { + static let title: LocalizedStringResource = "Cheap power" + static let isDiscoverable = false + + @Parameter(title: "Start") + var startMs: Double? + + @Parameter(title: "End") + var endMs: Double? + + init() {} + + init(_ w: WatchGlance.CheapWindow) { + startMs = w.startMs + endMs = w.endMs + } +} + +struct CheapEntry: RelevanceEntry { + let window: WatchGlance.CheapWindow? + /// "Fetched 13:05" + let fetched: String? +} + +struct CheapProvider: RelevanceEntriesProvider { + /// Shown a little before the window opens, so it is there when it does. + static let leadMs: Double = 15 * 60_000 + + func relevance() async -> WidgetRelevance { + let prices = Kept.read().prices + let windows = WatchGlance.cheapWindows(prices?.value, nowMs: Date.now.timeIntervalSince1970 * 1000) + log.info("cheap power: \(windows.count, privacy: .public) windows") + return WidgetRelevance(windows.map { w in + WidgetRelevanceAttribute( + configuration: CheapWindowIntent(w), + context: .date(interval: DateInterval(start: Date(timeIntervalSince1970: (w.startMs - Self.leadMs) / 1000), end: Date(timeIntervalSince1970: w.endMs / 1000)), kind: .default) + ) + }) + } + + func entry(configuration: CheapWindowIntent, context: Context) async throws -> CheapEntry { + let prices = Kept.read().prices + let nowMs = Date.now.timeIntervalSince1970 * 1000 + let window = WatchGlance.cheapWindows(prices?.value, nowMs: nowMs).first { $0.startMs == configuration.startMs } + let fetched = prices.map { PriceGlance.moment($0, atMs: nowMs).fetchedText } + return CheapEntry(window: window, fetched: fetched ?? nil) + } + + func placeholder(context: Context) -> CheapEntry { + CheapEntry(window: nil, fetched: nil) + } +} + +struct CheapView: View { + let entry: CheapEntry + + var body: some View { + VStack(alignment: .leading, spacing: 1) { + Text(WatchWord.cheapNow.text) + .font(.headline) + .widgetAccentable() + if let w = entry.window { + Text("\(w.range) · \(w.price)") + .font(.body.monospacedDigit()) + .lineLimit(1) + .minimumScaleFactor(0.8) + } + Text(entry.fetched?.uppercased() ?? "") + .font(.system(.caption2, design: .monospaced)) + .foregroundStyle(.secondary) + } + .frame(maxWidth: .infinity, alignment: .leading) + .containerBackground(for: .widget) { Theme.ground } + .redacted(reason: entry.window == nil ? .placeholder : []) + } +} diff --git a/appleApp/FTWWatchWidgets/PlanComplication.swift b/appleApp/FTWWatchWidgets/PlanComplication.swift new file mode 100644 index 0000000..2a20fd4 --- /dev/null +++ b/appleApp/FTWWatchWidgets/PlanComplication.swift @@ -0,0 +1,128 @@ +import FTWShared +import SwiftUI +import WidgetKit + +/// The plan on the watch face. Circular: the dial, a ring of prices with +/// the battery's action, the hand at now and the battery's level in the +/// middle while it is recent. Rectangular: what happens next, the price +/// strip and when the plan was made. Corner: the price now and when it is +/// cheapest. Inline: the price now. +struct PlanComplication: Widget { + var body: some WidgetConfiguration { + StaticConfiguration(kind: WatchKinds.plan, provider: FaceTimeline()) { entry in + PlanComplicationView(face: entry.face) + } + .configurationDisplayName(WatchWord.planWidget.text) + .description(WatchWord.planWidgetSummary.text) + .supportedFamilies([.accessoryCircular, .accessoryRectangular, .accessoryCorner, .accessoryInline]) + } +} + +struct FaceEntry: TimelineEntry { + let date: Date + let face: WatchGlance.Face +} + +struct FaceTimeline: TimelineProvider { + func placeholder(in context: Context) -> FaceEntry { + let now = Date.now.timeIntervalSince1970 * 1000 + return FaceEntry(date: .now, face: WatchGlance.faces(plan: nil, prices: nil, now: nil, nowMs: now)[0]) + } + + func getSnapshot(in context: Context, completion: @escaping (FaceEntry) -> Void) { + completion(entries(now: .now)[0]) + } + + /// The watch app reloads this whenever the phone hands over new + /// records. The entries step a quarter hour at a time, so the hand + /// moves without waking anything. + func getTimeline(in context: Context, completion: @escaping (Timeline) -> Void) { + let entries = entries(now: .now) + log.info("plan complication: \(entries.count, privacy: .public) entries") + completion(Timeline(entries: entries, policy: .never)) + } + + private func entries(now: Date) -> [FaceEntry] { + let kept = Kept.read() + return WatchGlance.faces(plan: kept.plan, prices: kept.prices, now: kept.now, nowMs: now.timeIntervalSince1970 * 1000) + .map { FaceEntry(date: Date(timeIntervalSince1970: $0.atMs / 1000), face: $0) } + } +} + +struct PlanComplicationView: View { + let face: WatchGlance.Face + @Environment(\.widgetFamily) private var family + + var body: some View { + switch family { + case .accessoryCircular: circular + case .accessoryRectangular: rectangular + case .accessoryCorner: corner + default: inline + } + } + + private var circular: some View { + ZStack { + if let dial = face.dial { + DialFace(dial: dial, grey: face.plan.isGrey, size: .complication) + } else { + Circle().stroke(Theme.ink3, lineWidth: 1) + } + Text(face.batteryPct.map { "\(Int($0.rounded()))" } ?? "–") + .font(.system(size: 12, weight: .semibold).monospacedDigit()) + .foregroundStyle(Theme.ink) + .widgetAccentable() + } + .containerBackground(for: .widget) { Theme.ground } + .accessibilityLabel(face.plan.summary) + } + + private var rectangular: some View { + VStack(alignment: .leading, spacing: 1) { + Text(face.plan.nextWhat.map { [face.plan.nextWhen, $0].compactMap { $0 }.joined(separator: " · ") } ?? face.plan.summary) + .font(.headline) + .lineLimit(1) + .minimumScaleFactor(0.8) + .widgetAccentable() + Text(face.plan.nextUntil ?? face.plan.cheapText ?? "") + .font(.caption2) + .foregroundStyle(.secondary) + .lineLimit(1) + if !face.price.curve.isEmpty { + BlockBars(bars: face.price.curve) + .frame(height: 10) + } + Text(planAge) + .font(.system(.caption2, design: .monospaced)) + .foregroundStyle(.secondary) + .lineLimit(1) + } + .containerBackground(for: .widget) { Theme.ground } + } + + private var corner: some View { + Text(face.price.priceText ?? "–") + .font(.system(size: 14, weight: .semibold).monospacedDigit()) + .widgetCurvesContent() + .widgetLabel { + Text(face.price.priceText == nil ? (face.price.emptyText ?? "") : (face.price.cheapestStartText ?? face.price.unitLabel)) + } + .containerBackground(for: .widget) { Theme.ground } + } + + /// The inline slot is as narrow as a date on some faces and the system + /// cuts what does not fit, so it carries the price now and no more. + private var inline: some View { + Text(face.price.priceText.map { "\($0) \(face.price.unitLabel)" } ?? face.price.inlineText) + .containerBackground(for: .widget) { Theme.ground } + } + + /// "PLANNED 16:45", or "PLAN FROM 14:05" once old. A complication + /// always says how old its plan is. + private var planAge: String { + guard let at = face.plan.readAtMs else { return face.price.fetchedText?.uppercased() ?? "" } + let clock = ZoneClock.of(face.plan.timeZone).label(at) + return (face.plan.state == .old ? WatchWord.planFrom : WatchWord.planned).format(clock) + } +} diff --git a/appleApp/FTWWatchWidgets/PrivacyInfo.xcprivacy b/appleApp/FTWWatchWidgets/PrivacyInfo.xcprivacy new file mode 100644 index 0000000..64e785c --- /dev/null +++ b/appleApp/FTWWatchWidgets/PrivacyInfo.xcprivacy @@ -0,0 +1,23 @@ + + + + + NSPrivacyTracking + + NSPrivacyTrackingDomains + + NSPrivacyCollectedDataTypes + + NSPrivacyAccessedAPITypes + + + NSPrivacyAccessedAPIType + NSPrivacyAccessedAPICategoryFileTimestamp + NSPrivacyAccessedAPITypeReasons + + C617.1 + + + + + diff --git a/appleApp/FTWWatchWidgets/WatchWidgets.swift b/appleApp/FTWWatchWidgets/WatchWidgets.swift new file mode 100644 index 0000000..95eaf58 --- /dev/null +++ b/appleApp/FTWWatchWidgets/WatchWidgets.swift @@ -0,0 +1,31 @@ +import FTWShared +import os +import SwiftUI +import WidgetKit + +let log = Logger(subsystem: "energy.ftw.app.watchkitapp", category: "complications") + +/// The watch's complications and its Smart Stack card. Like the phone's +/// widgets they read the records in the App Group and nothing else: the +/// watch app leaves there what the phone handed over. They never glow, and +/// every one says how old its plan or price is. +@main +struct FTWWatchWidgets: WidgetBundle { + var body: some Widget { + PlanComplication() + CheapPowerWidget() + } +} + +/// The records as the watch app last kept them, for the home it shows. +enum Kept { + static func read() -> (plan: CacheRead?, prices: CacheRead?, now: CacheRead?) { + guard let reader = CacheReader.shared() else { + log.error("no App Group, nothing to read") + return (nil, nil, nil) + } + let site = reader.latestSiteID(PlanEntry.self) ?? reader.latestSiteID(PriceEntry.self) + guard let site else { return (nil, nil, nil) } + return (reader.read(PlanEntry.self, siteId: site), reader.read(PriceEntry.self, siteId: site), reader.read(WatchNow.self, siteId: site)) + } +} diff --git a/appleApp/RELEASING.md b/appleApp/RELEASING.md index fb4985c..79005f8 100644 --- a/appleApp/RELEASING.md +++ b/appleApp/RELEASING.md @@ -10,7 +10,7 @@ Apple moves menus, so trust the screen if it differs. |---|---| | Seller | Sourceful Labs AB (reg. no. 559382-0458), Kalmar, Sweden | | Team ID | `L66M368PZ6` | -| Bundle IDs | `energy.ftw.app` (registered), `energy.ftw.app.widgets` | +| Bundle IDs | `energy.ftw.app` (registered), `energy.ftw.app.widgets`, `energy.ftw.app.notify`, `energy.ftw.app.watchkitapp`, `energy.ftw.app.watchkitapp.widgets` | | App Group | `group.energy.ftw.app` | | Workflow | [`.github/workflows/ios-release.yml`](../.github/workflows/ios-release.yml) | | Store texts | [`store/`](store/) | From 1d65dc56696488c51b5331aef7ded05429bf8224 Mon Sep 17 00:00:00 2001 From: Fredrik Ahlgren Date: Wed, 14 Oct 2026 14:21:19 +0200 Subject: [PATCH 2/2] iPhone: history and ahead on the engine, in the live trace's material, the chart small and growing (WP7) (#44) * iPhone: history and ahead on the engine, the chart small and growing (WP7) Today and 48 h draw from HistoryPicture: a fixed day with 30-minute slots, 48 h with 60-minute slots, each slot the mean of the box's five-minute means through Steffen, with its band. One stable scale for the chosen series. Holes on their way are hatched with a dashed estimate; holes with no record are a dotted baseline. AS OF marks old data. A tile that lands moves each slot to its new mean. Ahead draws from AheadPicture: plan quarters as ghost bars, forecasts as a dashed line, the price strip under the same slots. Fredrik's note 16: the chart is compact and a tap zooms it to the whole screen. Chips add or take away series, kept per viewer per node; the last one stays and a hidden series changes nothing the others say. The scale re-fits as one spring. Size caps for the largest text move to where each view is placed, so @ScaledMetric sees them; this fixes the price and node heroes too. NodeCharts, ChartMotion and ChartScrub are gone. Co-Authored-By: Claude Opus 5.5 * iPhone: every power trace in the live trace's material (WP7) Fredrik on 0.3.5: the live view feels right, so every other chart should feel and look the same. Live, Today and 48 h, small and enlarged, now draw through one place, TraceInk: the same band, body, 2.5 pt line, and glow only while live (on Today, only the slot still filling). Live uses ChartAxes and NodeChartLook like the rest. SlidingSurface gives history and Ahead Live's motion: a change for the same slots slides over 0.25 s, a new scale or a series coming or going springs over 0.35 s, with the same ease. AheadPicture.blended moves a new plan or forecast slot by slot. Under a finger the hero follows on Today and 48 h, as on Live, and the line under the chart says the energy so far or between, from the same functions. The dot takes its side's colour, the span tag gives the average, and nothing dims behind the finger. History's landmarks are the high, the low, now and each end of a run of data, as on Live. The debug finger reads the newest track; HistoryTour starts on Live. Co-Authored-By: Claude Opus 5.5 --------- Co-authored-by: Claude Opus 5.5 --- CLAUDE.md | 11 + appleApp/FTW/UI/ChartMotion.swift | 139 ------ appleApp/FTW/UI/ChartScrub.swift | 122 ----- appleApp/FTW/UI/NodeCharts.swift | 441 ------------------ appleApp/FTW/UI/NodeHistory.swift | 412 ++++++++++++++++ appleApp/FTW/UI/NodeView.swift | 361 +++++++------- appleApp/FTW/UI/PlanCard.swift | 8 + appleApp/FTW/UI/PriceView.swift | 1 + appleApp/FTWCharts/HistoryCharts.swift | 312 +++++++++++++ appleApp/FTWCharts/LiveTrace.swift | 96 +--- appleApp/FTWCharts/ScrubOverlay.swift | 36 +- appleApp/FTWCharts/TraceInk.swift | 86 ++++ .../Sources/FTWKit/State/ChartWords.swift | 41 -- .../Sources/FTWKit/State/NodeLive.swift | 25 + .../Sources/FTWKit/State/NodeWords.swift | 5 + .../FTWShared/Charts/AheadPicture.swift | 130 ++++++ .../FTWShared/Charts/ChartSeries.swift | 178 +++++-- .../FTWShared/Charts/HistoryPicture.swift | 116 +++++ .../Tests/FTWKitTests/NodeLiveTests.swift | 15 + .../FTWKit/Tests/FTWKitTests/NodeTests.swift | 26 +- .../FTWSharedTests/ChartGeometryTests.swift | 2 +- .../FTWSharedTests/HistoryRecipeTests.swift | 191 ++++++++ .../Resources/sungrow-days.json | 1 + appleApp/FTWUITests/HistoryTour.swift | 73 +++ appleApp/FTWUITests/NowFitsTests.swift | 2 + 25 files changed, 1733 insertions(+), 1097 deletions(-) delete mode 100644 appleApp/FTW/UI/ChartMotion.swift delete mode 100644 appleApp/FTW/UI/ChartScrub.swift delete mode 100644 appleApp/FTW/UI/NodeCharts.swift create mode 100644 appleApp/FTW/UI/NodeHistory.swift create mode 100644 appleApp/FTWCharts/HistoryCharts.swift create mode 100644 appleApp/FTWCharts/TraceInk.swift create mode 100644 appleApp/FTWKit/Sources/FTWShared/Charts/AheadPicture.swift create mode 100644 appleApp/FTWKit/Sources/FTWShared/Charts/HistoryPicture.swift create mode 100644 appleApp/FTWKit/Tests/FTWSharedTests/HistoryRecipeTests.swift create mode 100644 appleApp/FTWKit/Tests/FTWSharedTests/Resources/sungrow-days.json create mode 100644 appleApp/FTWUITests/HistoryTour.swift diff --git a/CLAUDE.md b/CLAUDE.md index e9bbba9..bd704c2 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -117,6 +117,17 @@ native, and smooth enough to impress. live is drawn in ink on the same curve. No dot travels along a line. - **Every chart takes a finger.** The point under it is marked, and the line under the chart says what was there. Two fingers give the span. +- **History sits small and grows** (Fredrik, 14 Oct 2026). A node's chart + is compact, so the view never scrolls for it; a tap opens it on the + whole screen, growing out of the small one. Chips add or take away + series, kept per viewer; the last one stays, and a hidden series never + changes what the others say. +- **One material for every power trace** (Fredrik, 14 Oct 2026). Live, + Today and 48 h, small and enlarged, draw through `TraceInk` (band, + body, line, glow only while live), `ChartAxes`, `LockOn` and + `SlidingSurface` (a slide of 0.25 s, a spring of 0.35 s), and the hero + follows the finger on each. Going from Live to Today changes the time, + never the material. A new chart starts from these. - **Charts draw on the engine.** Geometry is a value built once per data change (`FTWShared/Charts`, tested). A chart is one Canvas (`ChartSurface`) that never sees the finger and a small `ScrubOverlay` diff --git a/appleApp/FTW/UI/ChartMotion.swift b/appleApp/FTW/UI/ChartMotion.swift deleted file mode 100644 index 3e67a7b..0000000 --- a/appleApp/FTW/UI/ChartMotion.swift +++ /dev/null @@ -1,139 +0,0 @@ -import FTWKit -import SwiftUI - -/// How a chart's points move when its data changes: each point that -/// changed springs from where it was drawn to its new value, a few -/// milliseconds after the point before it, so a curve grows into shape -/// rather than jumping. An estimate the data replaces, or a hole the box -/// confirms, fades as the line settles. Under Reduce Motion the new data -/// shows at once and only the fade remains. -/// -/// The points are computed once per change of data; the motion only -/// interpolates between two lists it already has. -enum ChartMotion { - /// The spring Fredrik's brief names: response 0.55 s, damping 0.72. - static let response = 0.55 - static let damping = 0.72 - /// Between one changed point and the next. - static let stagger = 0.008 - /// The whole stagger never takes longer than this, however many points. - static let staggerCap = 0.35 - /// How long a fading estimate takes to go. - static let fade = 0.3 - - /// A damped spring's step from 0 to 1 at `t` seconds, as - /// `.spring(response:dampingFraction:)` moves a value. It overshoots a - /// little and settles. - static func spring(_ t: Double) -> Double { - guard t > 0 else { return 0 } - let omega = 2 * Double.pi / response - let zeta = damping - let wd = omega * (1 - zeta * zeta).squareRoot() - let decay = exp(-zeta * omega * t) - return 1 - decay * (cos(wd * t) + zeta * omega / wd * sin(wd * t)) - } - - /// When a spring has settled to within a hair of its value. - static var settle: Double { response * 2 } -} - -/// One change of a chart's points, followed while it moves. -struct ChartChange: Equatable { - /// Where each changed point was drawn when the change began, by id. - var from: [Double: Double] = [:] - /// Each changed point's place in the stagger, by id. - var order: [Double: Int] = [:] - /// Estimates the change took away, drawn fading. - var fading: [HistoryPlot.Point] = [] - var began: Date = .distantPast - var stagger: Double = ChartMotion.stagger - - /// How long this change moves for. - var duration: Double { ChartMotion.settle + Double(order.count) * stagger } - - /// The points as drawn at `date`. - func drawn(_ points: [HistoryPlot.Point], at date: Date) -> [HistoryPlot.Point] { - guard !order.isEmpty else { return points } - let elapsed = date.timeIntervalSince(began) - return points.map { p in - guard let i = order[p.id], let start = from[p.id] else { return p } - let f = ChartMotion.spring(elapsed - Double(i) * stagger) - return HistoryPlot.Point(t: p.t, v: start + (p.v - start) * f, kind: p.kind, run: p.run, id: p.id) - } - } - - /// How much of the fading estimates is left at `date`. - func fadeLeft(at date: Date) -> Double { - max(0, 1 - date.timeIntervalSince(began) / ChartMotion.fade) - } - - /// The change from what was drawn to `new`. A point that is new springs - /// from its neighbour on the left, or from zero on a chart's first data, - /// so it grows in rather than appearing. - static func between(_ old: [HistoryPlot.Point], drawn: [HistoryPlot.Point], _ new: [HistoryPlot.Point], at date: Date, still: Bool) -> ChartChange { - var change = ChartChange(began: date) - let before = Dictionary(drawn.map { ($0.id, $0) }, uniquingKeysWith: { a, _ in a }) - let oldByID = Dictionary(old.map { ($0.id, $0) }, uniquingKeysWith: { a, _ in a }) - let newIDs = Set(new.filter { $0.kind == .estimate }.map(\.id)) - change.fading = old.filter { $0.kind == .estimate && !newIDs.contains($0.id) } - guard !still else { return change } - var order = 0 - var left: Double = 0 - for p in new { - if let was = before[p.id] { - if abs(was.v - p.v) > 0.5 || oldByID[p.id]?.kind != p.kind { - change.from[p.id] = was.v - change.order[p.id] = order - order += 1 - } - } else { - change.from[p.id] = old.isEmpty ? 0 : left - change.order[p.id] = order - order += 1 - } - left = p.v - } - if order > 0 { change.stagger = min(ChartMotion.stagger, ChartMotion.staggerCap / Double(order)) } - return change - } -} - -/// Holds a chart's points and moves between them: the chart draws what -/// `content` is handed, at the display's rate while a change moves and not -/// at all otherwise. -struct MovingPoints: View { - let points: [HistoryPlot.Point] - @ViewBuilder let content: (_ drawn: [HistoryPlot.Point], _ fading: [HistoryPlot.Point], _ fadeLeft: Double) -> Content - @Environment(\.accessibilityReduceMotion) private var reduceMotion - @State private var shown: [HistoryPlot.Point] = [] - @State private var change = ChartChange() - @State private var moving = false - - var body: some View { - TimelineView(.animation(paused: !moving)) { context in - let now = moving ? context.date : .distantFuture - content(change.drawn(shown, at: now), change.fading, change.fading.isEmpty ? 0 : change.fadeLeft(at: now)) - } - .onAppear { begin(points) } - .onChange(of: points) { _, new in begin(new) } - } - - private func begin(_ new: [HistoryPlot.Point]) { - let now = Date() - // Interrupted mid-move: start from where the points are drawn now. - let drawn = change.drawn(shown, at: now) - change = ChartChange.between(shown, drawn: drawn, new, at: now, still: reduceMotion) - shown = new - let length = max(change.order.isEmpty ? 0 : change.duration, change.fading.isEmpty ? 0 : ChartMotion.fade) - guard length > 0 else { - moving = false - return - } - moving = true - let began = change.began - Task { @MainActor in - try? await Task.sleep(for: .seconds(length + 0.05)) - if change.began == began { moving = false } - } - } -} diff --git a/appleApp/FTW/UI/ChartScrub.swift b/appleApp/FTW/UI/ChartScrub.swift deleted file mode 100644 index de09a85..0000000 --- a/appleApp/FTW/UI/ChartScrub.swift +++ /dev/null @@ -1,122 +0,0 @@ -import Charts -import FTWKit -import SwiftUI - -/// The line under a chart: its readout while the finger is down, else its -/// own sentence. The swap is instant, as the finger is, and nothing below -/// them moves. -struct ReadoutLine: View { - let readout: ChartReadout? - let otherwise: String - - var body: some View { - ZStack(alignment: .topLeading) { - // The sentence keeps its room, so a shorter readout does not - // pull the card up. - Text(otherwise) - .opacity(readout == nil ? 1 : 0) - if let readout { - (Text(readout.when).font(.footnote.weight(.semibold).monospacedDigit()).foregroundStyle(Theme.fg) - + Text(verbatim: " ") - + Text(readout.what).foregroundStyle(Theme.fg)) - .accessibilityIdentifier("chart.readout") - } - } - .font(.footnote) - .foregroundStyle(Theme.fgDim) - .fixedSize(horizontal: false, vertical: true) - .frame(maxWidth: .infinity, alignment: .leading) - .transaction { $0.animation = nil } - } -} - -/// The finger on a chart: one slot, or two fingers' range. A light tick -/// per slot (never more than about 25 a second) and a firmer one at a -/// landmark: now, the edges of the cheapest hours, the day's high and low, -/// the car's goal. Nothing on a data update. -struct ChartScrub: ViewModifier { - @Binding var selected: Date? - @Binding var range: ClosedRange? - /// The slot a moment falls in, so a tick marks a step, not a pixel. - let slot: (Date) -> Double? - /// Whether a slot is a landmark. - let landmark: (Double) -> Bool - @State private var ticks = 0 - @State private var bumps = 0 - @State private var lastSlot: Double? - @State private var lastTick: ContinuousClock.Instant? - - func body(content: Content) -> some View { - // The selection never animates: the marker is where the finger is. - content - .chartXSelection(value: $selected) - .chartXSelection(range: $range) - .onChange(of: selected) { _, date in - guard let date else { - lastSlot = nil - return - } - // A moment with no slot keeps the last one, so coming back - // to it does not tick again. - guard let s = slot(date), s != lastSlot else { return } - lastSlot = s - let now = ContinuousClock.now - if landmark(s) { - bumps += 1 - } else if lastTick.map({ now - $0 >= .milliseconds(40) }) ?? true { - lastTick = now - ticks += 1 - } - } - .sensoryFeedback(.selection, trigger: ticks) - .sensoryFeedback(.impact(weight: .light), trigger: bumps) - } -} - -extension View { - func chartScrub(selected: Binding, range: Binding?>, slot: @escaping (Date) -> Double?, landmark: @escaping (Double) -> Bool = { _ in false }) -> some View { - modifier(ChartScrub(selected: selected, range: range, slot: slot, landmark: landmark)) - } -} - -/// The marks for the place under the finger: a thin rule, and a dot on the -/// line where there is one. -@ChartContentBuilder -func scrubMarks(_ date: Date?, value: Double?, color: Color) -> some ChartContent { - if let date { - RuleMark(x: .value("Selected", date)) - .foregroundStyle(Theme.fgDim.opacity(0.6)) - .lineStyle(StrokeStyle(lineWidth: 1)) - if let value { - PointMark(x: .value("Selected", date), y: .value("Power", value)) - .foregroundStyle(color) - .symbolSize(60) - } - } -} - -/// History as VoiceOver reads it: an hour a step, with the power and which -/// way it flowed. -struct HistoryDescriptor: AXChartDescriptorRepresentable { - let node: EnergyNode - let samples: [HistorySample] - let fromMs: Double - - func makeChartDescriptor() -> AXChartDescriptor { - let hour: Double = 3_600_000 - var hours: [(t: Double, v: Double)] = [] - let byHour = Dictionary(grouping: samples.filter { $0.watts != nil }) { ($0.startMs / hour).rounded(.down) } - for key in byHour.keys.sorted() { - let values = byHour[key]!.compactMap(\.watts) - hours.append((max(fromMs, key * hour), values.reduce(0, +) / Double(values.count))) - } - let lo = min(0, hours.map(\.v).min() ?? 0), hi = max(0, hours.map(\.v).max() ?? 0) - let end = samples.last.map { $0.startMs + $0.stepMs } ?? fromMs - let x = AXNumericDataAxisDescriptor(title: L("Time"), range: fromMs...max(fromMs + 1, end), gridlinePositions: []) { Readouts.clock($0) } - let y = AXNumericDataAxisDescriptor(title: L("Power"), range: lo...max(lo + 1, hi), gridlinePositions: []) { AppWords.power(abs($0)) } - let series = AXDataSeriesDescriptor(name: node.title, isContinuous: true, dataPoints: hours.map { - AXDataPoint(x: $0.t, y: $0.v, additionalValues: [], label: Readouts.word(node, $0.v)) - }) - return AXChartDescriptor(title: node.title, summary: nil, xAxis: x, yAxis: y, additionalAxes: [], series: [series]) - } -} diff --git a/appleApp/FTW/UI/NodeCharts.swift b/appleApp/FTW/UI/NodeCharts.swift deleted file mode 100644 index deaab63..0000000 --- a/appleApp/FTW/UI/NodeCharts.swift +++ /dev/null @@ -1,441 +0,0 @@ -import Charts -import FTWKit -import SwiftUI - -/// The curves of a node's view. Each is `Equatable` on the values it draws, -/// so a frame that changed nothing it shows does not redraw it. -/// -/// Watts are drawn as the node shows them (solar as what it makes), signed -/// where direction matters: up is into the home's battery or from the grid, -/// down is out. The axis says magnitudes and a word says which way; no -/// minus sign reaches the screen. - -private func date(_ ms: Double) -> Date { Date(timeIntervalSince1970: ms / 1000) } - -/// "15", on the 24-hour clock FTW uses in both languages: an English phone -/// would otherwise label the afternoon "03". -func hourLabel(_ value: AxisValue) -> some View { - Text(value.as(Date.self).map { String(format: "%02d", Calendar.current.component(.hour, from: $0)) } ?? "") - .font(.caption2) - .foregroundStyle(Theme.fgMuted) -} - -/// The largest text a chart's labels take: a chart is a drawing, and its -/// figures are said again in words under it and to VoiceOver. -let chartTextCap = DynamicTypeSize.xxLarge - -/// An axis label in a column of one width for the text size, so charts -/// stacked one above the other put the same hour at the same place. -struct AxisText: View { - let text: String - @ScaledMetric(relativeTo: .caption2) private var width: CGFloat = 44 - - init(_ text: String) { self.text = text } - - var body: some View { - Text(text) - .font(.caption2) - .foregroundStyle(Theme.fgMuted) - .lineLimit(1) - .frame(width: width, alignment: .leading) - } -} - -/// Axis labels as magnitudes: "2 kW" above the line and below it. The zero -/// line speaks for itself. -private func powerAxis() -> some AxisContent { - AxisMarks(position: .trailing, values: .automatic(desiredCount: 3)) { value in - AxisGridLine(stroke: StrokeStyle(lineWidth: 0.5)).foregroundStyle(Theme.line) - AxisValueLabel { - if let w = value.as(Double.self), w != 0 { - AxisText(PowerFormat.scale(w)) - } - } - } -} - -/// How a node's charts draw it: one colour pair for every chart of the -/// node, live, past and ahead, and for a node whose power runs both ways -/// the word for each direction inside the plot. -struct ChartLook: Equatable { - /// Into the battery, from the grid, or the node's only direction. - var up: Color - /// Out of the battery, to the grid. - var down: Color - /// The line over the areas. - var line: Color - var upWord: String? - var downWord: String? - - var signed: Bool { upWord != nil } -} - -/// "↑ charging" at the top of the plot and "↓ giving power" at its foot, so -/// a signed curve says which way is which where the eye already is. -private struct DirectionWords: ViewModifier { - let look: ChartLook - - func body(content: Content) -> some View { - content.chartPlotStyle { plot in - plot - .overlay(alignment: .topLeading) { word(look.upWord, "arrow.up") } - .overlay(alignment: .bottomLeading) { word(look.downWord, "arrow.down") } - } - } - - @ViewBuilder private func word(_ text: String?, _ symbol: String) -> some View { - if let text { - Label(text, systemImage: symbol) - .labelStyle(.titleAndIcon) - .font(.caption2.weight(.medium)) - .foregroundStyle(Theme.fgDim) - .lineLimit(1) - .padding(.horizontal, 4) - .padding(.vertical, 2) - .allowsHitTesting(false) - .accessibilityHidden(true) - } - } -} - -extension View { - func directionWords(_ look: ChartLook) -> some View { modifier(DirectionWords(look: look)) } -} - -/// A run of points drawn as two areas, the part above zero in one colour of -/// the pair and the part below in the other, under one line. -@ChartContentBuilder -private func signedRun(_ t: Double, _ v: Double, run: Int, look: ChartLook, opacity: (top: Double, bottom: Double), lineWidth: CGFloat) -> some ChartContent { - AreaMark(x: .value("Time", date(t)), yStart: .value("Zero", 0), yEnd: .value("Power", max(0, v)), series: .value("Run", "\(run)+")) - .foregroundStyle(LinearGradient(colors: [look.up.opacity(opacity.top), look.up.opacity(opacity.bottom)], startPoint: .top, endPoint: .bottom)) - .interpolationMethod(.monotone) - if look.signed { - AreaMark(x: .value("Time", date(t)), yStart: .value("Power", min(0, v)), yEnd: .value("Zero", 0), series: .value("Run", "\(run)-")) - .foregroundStyle(LinearGradient(colors: [look.down.opacity(opacity.bottom), look.down.opacity(opacity.top)], startPoint: .top, endPoint: .bottom)) - .interpolationMethod(.monotone) - } - LineMark(x: .value("Time", date(t)), y: .value("Power", v), series: .value("Run", "\(run)")) - .foregroundStyle(look.line) - .lineStyle(StrokeStyle(lineWidth: lineWidth, lineCap: .round)) - .interpolationMethod(.monotone) -} - -/// Today or the last two days, five minutes a point, from the box's tiles. -struct HistoryChart: View, Equatable { - let node: EnergyNode - let samples: [HistorySample] - let fromMs: Double - let toMs: Double - let look: ChartLook - let strideHours: Int - /// What the finger is on, for the line under the chart. - var onScrub: (ChartReadout?) -> Void = { _ in } - @State private var selected: Date? - @State private var range: ClosedRange? - - nonisolated static func == (a: Self, b: Self) -> Bool { - a.node == b.node && a.samples == b.samples && a.fromMs == b.fromMs && a.toMs == b.toMs && a.look == b.look && a.strideHours == b.strideHours - } - - var body: some View { - // Worked out once per change of data; the motion only moves - // between two lists. - MovingPoints(points: HistoryPlot.points(samples, fromMs: fromMs)) { drawn, fading, fadeLeft in - Chart { - if look.signed { - RuleMark(y: .value("Zero", 0)).foregroundStyle(Theme.line) - } - ForEach(drawn.filter { $0.kind == .measured }) { p in - signedRun(p.t, p.v, run: p.run, look: look, opacity: (0.35, 0.05), lineWidth: 1.5) - } - // A stretch still on its way: a faint band that says so, and - // a dashed estimate across it, never a value. - ForEach(Array(Self.bridges(drawn).enumerated()), id: \.offset) { i, bridge in - if let span = estimateSpan(bridge) { - RectangleMark(xStart: .value("From", date(span.lowerBound)), xEnd: .value("To", date(span.upperBound))) - .foregroundStyle(Theme.fgDim.opacity(0.07)) - .annotation(position: .overlay, alignment: .top) { - Text(L("fetching…")) - .font(.caption2) - .foregroundStyle(Theme.fgMuted) - .padding(.top, 4) - } - } - ForEach(bridge) { p in - LineMark(x: .value("Time", date(p.t)), y: .value("Power", p.v), series: .value("Estimate", "e\(i)")) - .foregroundStyle(look.line.opacity(0.6)) - .lineStyle(StrokeStyle(lineWidth: 1.5, lineCap: .round, dash: [3, 4])) - } - } - if fadeLeft > 0 { - ForEach(fading) { p in - LineMark(x: .value("Time", date(p.t)), y: .value("Power", p.v), series: .value("Estimate", "gone \(p.run)")) - .foregroundStyle(look.line.opacity(0.6 * fadeLeft)) - .lineStyle(StrokeStyle(lineWidth: 1.5, lineCap: .round, dash: [3, 4])) - } - } - if let range { - RectangleMark(xStart: .value("From", range.lowerBound), xEnd: .value("To", range.upperBound)) - .foregroundStyle(Theme.fgDim.opacity(0.12)) - } - scrubMarks(selected, value: selectedSample?.watts, color: (selectedSample?.watts ?? 0) < 0 ? look.down : look.line) - } - .chartXScale(domain: date(fromMs)...date(toMs)) - .chartXAxis { - AxisMarks(values: .stride(by: .hour, count: strideHours)) { value in - AxisGridLine(stroke: StrokeStyle(lineWidth: 0.5)).foregroundStyle(Theme.line) - AxisValueLabel { hourLabel(value) } - } - } - .chartYAxis { powerAxis() } - .chartYScale(domain: .automatic(includesZero: true)) - .directionWords(look) - .chartScrub(selected: $selected, range: $range, slot: { d in - samples.last { $0.startMs <= d.timeIntervalSince1970 * 1000 }?.startMs - }, landmark: { slot in landmarks.contains(slot) }) - } - .dynamicTypeSize(...chartTextCap) - .onChange(of: selected) { _, _ in report() } - .onChange(of: range) { _, _ in report() } - .accessibilityChartDescriptor(HistoryDescriptor(node: node, samples: samples, fromMs: fromMs)) - } - - private var selectedSample: HistorySample? { - guard let selected else { return nil } - let ms = selected.timeIntervalSince1970 * 1000 - return samples.last { $0.startMs <= ms } - } - - /// The day's high and low, for a firmer tick. - private var landmarks: Set { - let measured = samples.filter { $0.watts != nil } - var out = Set() - if let hi = measured.max(by: { $0.watts! < $1.watts! }) { out.insert(hi.startMs) } - if let lo = measured.min(by: { $0.watts! < $1.watts! }) { out.insert(lo.startMs) } - return out - } - - private func report() { - if let range { - onScrub(Readouts.history(node, samples, range: range.lowerBound.timeIntervalSince1970 * 1000...range.upperBound.timeIntervalSince1970 * 1000)) - } else if let selected { - onScrub(Readouts.history(node, samples, fromMs: fromMs, at: selected.timeIntervalSince1970 * 1000)) - } else { - onScrub(nil) - } - } - - /// The steps a bridge stands in for. - private func estimateSpan(_ bridge: [HistoryPlot.Point]) -> ClosedRange? { - let estimates = bridge.filter { $0.kind == .estimate } - guard let first = estimates.first, let last = estimates.last else { return nil } - let step = samples.first?.stepMs ?? 300_000 - return max(fromMs, first.id)...(last.id + step) - } - - /// Each run of estimates with the measured point either side, so the - /// dashed line meets the solid one. - static func bridges(_ points: [HistoryPlot.Point]) -> [[HistoryPlot.Point]] { - var out = [[HistoryPlot.Point]]() - var current = [HistoryPlot.Point]() - var previous: HistoryPlot.Point? - for p in points { - if p.kind == .estimate { - if current.isEmpty, let previous, previous.kind == .measured { current.append(previous) } - current.append(p) - } else if !current.isEmpty { - current.append(p) - out.append(current) - current = [] - } - previous = p - } - if !current.isEmpty { out.append(current) } - return out - } -} - -/// The car's energy hour by hour, from the box's ledger. -struct CarHoursChart: View, Equatable { - let hours: [CarEnergyModel.Hour] - let fromMs: Double - let toMs: Double - let strideHours: Int - var onScrub: (ChartReadout?) -> Void = { _ in } - @State private var selected: Date? - @State private var range: ClosedRange? - - nonisolated static func == (a: Self, b: Self) -> Bool { - a.hours == b.hours && a.fromMs == b.fromMs && a.toMs == b.toMs && a.strideHours == b.strideHours - } - - var body: some View { - Chart { - ForEach(hours.filter { $0.startMs + $0.lenMs > fromMs }) { h in - RectangleMark( - xStart: .value("From", date(max(fromMs, h.startMs))), - xEnd: .value("To", date(h.startMs + h.lenMs)), - yStart: .value("Zero", 0), - yEnd: .value("Energy", h.wh / 1000) - ) - .foregroundStyle(Theme.mobility) - .cornerRadius(2) - } - scrubMarks(selected, value: nil, color: Theme.mobility) - } - .chartScrub(selected: $selected, range: $range, slot: { d in - let ms = d.timeIntervalSince1970 * 1000 - return hours.first { $0.startMs <= ms && ms < $0.startMs + $0.lenMs }?.startMs - }) - .onChange(of: selected) { _, d in onScrub(d.flatMap { Readouts.carHour(hours, at: $0.timeIntervalSince1970 * 1000) }) } - .chartXScale(domain: date(fromMs)...date(toMs)) - .chartXAxis { - AxisMarks(values: .stride(by: .hour, count: strideHours)) { value in - AxisGridLine(stroke: StrokeStyle(lineWidth: 0.5)).foregroundStyle(Theme.line) - AxisValueLabel { hourLabel(value) } - } - } - .chartYAxis { - AxisMarks(position: .trailing, values: .automatic(desiredCount: 3)) { value in - AxisGridLine(stroke: StrokeStyle(lineWidth: 0.5)).foregroundStyle(Theme.line) - AxisValueLabel { - if let kwh = value.as(Double.self), kwh != 0 { - AxisText(AppWords.energy(kwh * 1000)) - } - } - } - } - .dynamicTypeSize(...chartTextCap) - } -} - -/// The hours ahead: what the box means each slot to do, as bars, and the -/// price under the same hours, so a plan and its reason sit side by side. -struct AheadChart: View, Equatable { - typealias Bar = ChartSlot - typealias Price = ChartPrice - - let node: EnergyNode - let bars: [Bar] - let prices: [Price] - let currency: String - let nowMs: Double - let look: ChartLook - /// Whether the bars are a plan (battery, grid, car) or a forecast. - var forecast = false - var onScrub: (ChartReadout?) -> Void = { _ in } - @State private var selected: Date? - @State private var range: ClosedRange? - - nonisolated static func == (a: Self, b: Self) -> Bool { - a.node == b.node && a.bars == b.bars && a.prices == b.prices && a.currency == b.currency && a.nowMs == b.nowMs && a.look == b.look && a.forecast == b.forecast - } - - var body: some View { - let to = nowMs + PlanModel.aheadMs - VStack(spacing: 8) { - Chart { - ForEach(bars) { b in - RectangleMark( - xStart: .value("From", date(max(nowMs, b.startMs))), - xEnd: .value("To", date(min(to, b.endMs))), - yStart: .value("Zero", 0), - yEnd: .value("Power", b.watts) - ) - .foregroundStyle(b.watts >= 0 ? look.up : look.down) - .cornerRadius(1.5) - } - if look.signed { - RuleMark(y: .value("Zero", 0)).foregroundStyle(Theme.line) - } - if let range { - RectangleMark(xStart: .value("From", range.lowerBound), xEnd: .value("To", range.upperBound)) - .foregroundStyle(Theme.fgDim.opacity(0.12)) - } - scrubMarks(selected, value: nil, color: look.line) - } - .chartXScale(domain: date(nowMs)...date(to)) - .chartXAxis(.hidden) - .chartYAxis { powerAxis() } - .chartYScale(domain: .automatic(includesZero: true)) - .directionWords(look) - .chartScrub(selected: $selected, range: $range, slot: { d in - Self.slot(bars, prices, d.timeIntervalSince1970 * 1000) - }, landmark: { slot in slot == bars.first?.startMs || slot == cheapest }) - .onChange(of: selected) { _, _ in report() } - .onChange(of: range) { _, _ in report() } - if !prices.isEmpty { - PriceStripChart(prices: prices, currency: currency, fromMs: nowMs, toMs: to) - .frame(height: 36) - } - } - .dynamicTypeSize(...chartTextCap) - } -} - -extension AheadChart { - /// The slot a moment falls in: a bar's, else a price's. - static func slot(_ bars: [Bar], _ prices: [Price], _ ms: Double) -> Double? { - bars.first { $0.startMs <= ms && ms < $0.endMs }?.startMs ?? prices.first { $0.startMs <= ms && ms < $0.endMs }?.startMs - } - - /// The cheapest price ahead, for a firmer tick. - private var cheapest: Double? { prices.min { $0.minor < $1.minor }?.startMs } - - private func report() { - if let range { - onScrub(Readouts.ahead(node, bars, prices, currency: currency, forecast: forecast, range: range.lowerBound.timeIntervalSince1970 * 1000...range.upperBound.timeIntervalSince1970 * 1000)) - } else if let selected { - onScrub(Readouts.ahead(node, bars, prices, currency: currency, forecast: forecast, at: max(nowMs, selected.timeIntervalSince1970 * 1000))) - } else { - onScrub(nil) - } - } -} - -/// Prices across the same hours as the chart above, coloured cheap to dear -/// against their own average, with the hours named under them. -struct PriceStripChart: View, Equatable { - let prices: [AheadChart.Price] - let currency: String - let fromMs: Double - let toMs: Double - - var body: some View { - let shown = prices.filter { $0.endMs > fromMs && $0.startMs < toMs } - let mean = shown.isEmpty ? 0 : shown.map(\.minor).reduce(0, +) / Double(shown.count) - Chart { - ForEach(shown) { p in - RectangleMark( - xStart: .value("From", date(max(fromMs, p.startMs))), - xEnd: .value("To", date(min(toMs, p.endMs))), - yStart: .value("Zero", 0), - yEnd: .value("Price", PriceUnits.display(p.minor, currency)) - ) - .foregroundStyle(Theme.price(p.minor > mean * 1.05 ? .dear : p.minor < mean * 0.95 ? .cheap : .flat)) - .accessibilityLabel(AppWords.clock(p.startMs, zone: .current)) - .accessibilityValue("\(AppWords.decimal(PriceStrip.text(p.minor, currency))) \(PriceUnits.unit(currency).perKwh)") - } - } - .chartXScale(domain: date(fromMs)...date(toMs)) - .chartXAxis { - AxisMarks(preset: .aligned, values: .stride(by: .hour, count: 3)) { value in - AxisValueLabel { hourLabel(value) } - } - } - .chartYAxis { - // The dearest hour, in the column the chart above keeps for its - // own scale: the price's scale, and the hours line up. - AxisMarks(position: .trailing, values: shown.map(\.minor).max().map { [PriceUnits.display($0, currency)] } ?? []) { value in - // Hung below its tick, inside the strip, clear of the scale - // of the chart above. - AxisValueLabel(anchor: .topLeading) { - if let v = value.as(Double.self) { - AxisText("\(Int(v.rounded())) \(PriceUnits.unit(currency).label)") - } - } - } - } - .accessibilityLabel(L("Prices ahead")) - } -} diff --git a/appleApp/FTW/UI/NodeHistory.swift b/appleApp/FTW/UI/NodeHistory.swift new file mode 100644 index 0000000..482b5a5 --- /dev/null +++ b/appleApp/FTW/UI/NodeHistory.swift @@ -0,0 +1,412 @@ +import FTWKit +import SwiftUI + +// MARK: - Which series a chart shows + +/// The series a node's history shows, per chart kind, kept per viewer. +/// The node's own series first; at least one always stays. +enum SeriesChoice { + static func key(_ node: EnergyNode) -> String { "ftw.chart.series.\(node.rawValue)" } + + static func load(_ node: EnergyNode) -> [EnergyNode] { + SeriesPick.read(UserDefaults.standard.string(forKey: key(node)), own: node) + } + + static func save(_ series: [EnergyNode], for node: EnergyNode) { + UserDefaults.standard.set(series.map(\.rawValue).joined(separator: ","), forKey: key(node)) + } +} + +/// Quiet chips for the series a history can show: on, a series speaks in +/// its colour with a small dot; off, it is ink. A tap adds or takes away a +/// series, and the last one stays. +struct SeriesChips: View { + let own: EnergyNode + let available: [EnergyNode] + @Binding var chosen: [EnergyNode] + @State private var taps = 0 + @ScaledMetric(relativeTo: .caption2) private var size: CGFloat = 10.5 + + var body: some View { + HStack(spacing: 6) { + ForEach(available, id: \.self) { node in + let on = chosen.contains(node) + Button { + let next = SeriesPick.toggled(chosen, node, own: own) + guard next != chosen else { return } + withAnimation(Theme.spring) { chosen = next } + SeriesChoice.save(next, for: own) + taps += 1 + } label: { + HStack(spacing: 4) { + Circle() + .fill(on ? NodeChartLook.color(node) : Color.clear) + .strokeBorder(on ? Color.clear : Theme.ink3, lineWidth: 1) + .frame(width: 6, height: 6) + Text(node.caps) + .font(.system(size: size, weight: .medium, design: .monospaced)) + .tracking(0.5) + .foregroundStyle(on ? NodeChartLook.color(node) : Theme.ink3) + } + .padding(.horizontal, 8) + .frame(minHeight: 28) + .contentShape(Capsule()) + } + .buttonStyle(.plain) + .accessibilityLabel(node.title) + .accessibilityAddTraits(on ? .isSelected : []) + .accessibilityIdentifier("chart.series.\(node.rawValue)") + } + Spacer(minLength: 0) + } + .lineLimit(1) + .dynamicTypeSize(...DynamicTypeSize.xxLarge) + .sensoryFeedback(.selection, trigger: taps) + } +} + +/// One colour pair per node, for every chart of it. +enum NodeChartLook { + static func color(_ node: EnergyNode) -> Color { + switch node { + case .solar: return Theme.solar + case .battery: return Theme.stores + case .home: return Theme.ink + case .grid: return Theme.grid + case .car: return Theme.car + } + } + + static func look(_ node: EnergyNode) -> TraceLook { + let words = node.directions + switch node { + case .battery: return TraceLook(up: Theme.stores, down: Theme.gives, upWord: words.map { "↑ " + $0.up.uppercased() }, downWord: words.map { "↓ " + $0.down.uppercased() }) + case .grid: return TraceLook(up: Theme.grid, down: Theme.grid, upWord: words.map { "↑ " + $0.up.uppercased() }, downWord: words.map { "↓ " + $0.down.uppercased() }) + default: return TraceLook(up: color(node), down: color(node)) + } + } +} + +// MARK: - What the line under the chart says + +/// The words for every slot of the chart on screen, made once per change of +/// data, so the line under the chart and the hero cost a subscript per +/// slot. +@Observable +@MainActor +final class PeriodText { + /// What the hero says under a finger, as on the live trace: the node's + /// own value and "14:30–15:00 · charging". No watts over a hole. + struct Hero { + var watts: Double? + var signed: Double + var word: String + } + + private(set) var slots: [String] = [] + private(set) var spans: (ClosedRange) -> String = { _ in "" } + private(set) var heroes: [Hero] = [] + private(set) var heroSpans: (ClosedRange) -> Hero? = { _ in nil } + + func set(_ slots: [String], spans: @escaping (ClosedRange) -> String, heroes: [Hero] = [], heroSpans: @escaping (ClosedRange) -> Hero? = { _ in nil }) { + self.slots = slots + self.spans = spans + self.heroes = heroes + self.heroSpans = heroSpans + } + + func text(_ scrub: ScrubModel) -> String? { + if let i = scrub.index, slots.indices.contains(i) { return slots[i] } + if let r = scrub.span, r.upperBound < slots.count { return spans(r) } + return nil + } + + func hero(_ scrub: ScrubModel) -> Hero? { + if let i = scrub.index, heroes.indices.contains(i) { return heroes[i] } + if let r = scrub.span, r.upperBound < heroes.count { return heroSpans(r) } + return nil + } +} + +// MARK: - Today and two days + +/// Today on its fixed day, or the last two days, on the engine, in the live +/// trace's material: each chosen series with its band, the node's own with +/// its body, holes as they are, and the finger reading one slot at a time, +/// up to the last slot with data. Under the finger the hero and the line +/// below speak as on Live. +struct HistoryChartArea: View { + let site: SiteModel + let node: EnergyNode + let home: HomeModels + let scrub: ScrubModel + let period: NodeView.Timebase + let series: [EnergyNode] + let text: PeriodText + @State private var size: CGSize = .zero + @State private var shown: YDomain? + @ScaledMetric(relativeTo: .caption2) private var axis: CGFloat = 10 + @ScaledMetric(relativeTo: .caption) private var tag: CGFloat = 11 + + var body: some View { + let frame = ChartFrame(top: tag * 1.35 + 8, bottom: axis * 1.4 + 4, trailing: axis * 3.2) + let plot = frame.plot(in: size).size + let picture = build(plot) + let own = picture?.layers.first + ZStack { + Color.clear + if let picture, let own { + let looks = Dictionary(uniqueKeysWithValues: picture.layers.compactMap { l in EnergyNode(rawValue: l.key).map { (l.key, NodeChartLook.look($0)) } }) + let labels = ChartLabels(fetching: L("FETCHING…"), asOf: picture.asOfMs.map { NodeWords.format("AS OF %@", AppWords.clock($0, zone: .current)) }, now: NodeWords.text("NOW")) + let live = site.freshness(node).live + SlidingSurface(picture: picture) { from, slide, spring in + HistoryDrawing(picture: picture, looks: looks, frame: frame, words: labels, axis: axis, live: live, from: from, slide: slide, spring: spring) + } + ScrubOverlay( + track: ScrubTrack( + grid: picture.grid, + domain: picture.domain, + values: own.series.buckets.map(\.mean), + atMs: own.series.buckets.map(\.atMs), + tags: own.series.readouts.when.indices.map { own.series.readouts.when[$0] + " " + (own.series.readouts.what[$0].components(separatedBy: " · ").first ?? "") }, + landmarks: own.series.readouts.landmarks.union([0, max(0, picture.limit ?? 0)]), + color: NodeChartLook.look(node).up, + downColor: NodeChartLook.look(node).down, + limit: picture.limit + ), + frame: frame, + model: scrub, + tagSize: tag, + // As on the live trace: "07:30–14:30 avg 522 W". + spanTag: { r in + let readouts = own.series.readouts + let when = (readouts.when[r.lowerBound].components(separatedBy: "–").first ?? "") + "–" + (readouts.when[r.upperBound].components(separatedBy: "–").last ?? "") + let means = own.series.buckets[r].compactMap(\.mean) + return means.isEmpty ? when : when + " " + NodeWords.format("avg %@", AppWords.power(abs(means.reduce(0, +) / Double(means.count)))) + } + ) + } else if let problem = home.nodes.history.problem { + Text(problem).font(.footnote).foregroundStyle(Theme.ink3).multilineTextAlignment(.center) + } + } + .dynamicTypeSize(...DynamicTypeSize.xxLarge) + .onGeometryChange(for: CGSize.self) { $0.size } action: { size = $0 } + .onChange(of: picture?.domain) { _, now in shown = now } + .onChange(of: picture, initial: true) { _, now in words(now) } + .accessibilityElement() + .accessibilityLabel(node.title) + .accessibilityChartDescriptor(PictureDescriptor(node: node, picture: picture)) + } + + /// The picture for the chosen series, the node's own first. + private func build(_ plot: CGSize) -> HistoryPicture? { + guard plot.width > 0, plot.height > 0 else { return nil } + let clock = ZoneClock.of(.current) + let now = site.minuteMs + 60_000 + let window = period == .today ? HistoryPicture.day(now, clock: clock) : HistoryPicture.twoDays(now) + var inputs: [(key: String, samples: [ChartSample])] = [] + for n in series { + let samples = Self.samples(n, home: home, fromMs: window.fromMs) + if !samples.isEmpty || n == node { inputs.append((n.rawValue, samples)) } + } + let words = ReadoutWords(value: { AppWords.power(abs($0)) }, range: { "\(AppWords.decimal(PowerFormat.fixed(abs($0) / 1000, 1)))–\(AppWords.decimal(PowerFormat.fixed(abs($1) / 1000, 1)))" }, none: NodeWords.text("no data")) + let weekday: @Sendable (Double) -> String = { ms in + Date(timeIntervalSince1970: ms / 1000).formatted(.dateTime.weekday(.abbreviated).locale(Locale(identifier: AppWords.language == .sv ? "sv_SE" : "en_US"))).uppercased().replacingOccurrences(of: ".", with: "") + } + return HistoryPicture.build(inputs, fromMs: window.fromMs, toMs: window.toMs, nowMs: now, size: plot, previous: shown, floor: node.scaleFloorW(), clock: clock, everyHours: period == .today ? 6 : 12, weekday: period == .today ? nil : weekday, words: words) + } + + /// One node's five-minute means as the chart reads them. The box keeps + /// no car tiles, so the car's hours stand in, each hour's mean power + /// over its twelve five-minute steps. + static func samples(_ node: EnergyNode, home: HomeModels, fromMs: Double) -> [ChartSample] { + if node == .car { + guard home.nodes.carEnergy.loaded else { return [] } + return home.nodes.carEnergy.hours.filter { $0.startMs + $0.lenMs > fromMs }.flatMap { h -> [ChartSample] in + let watts = h.wh * 3_600_000 / max(1, h.lenMs) + return stride(from: h.startMs, to: h.startMs + h.lenMs, by: 300_000).map { ChartSample(startMs: $0, value: watts) } + } + } + return home.nodes.history.samples(node, fromMs: fromMs).map { ChartSample(startMs: $0.startMs, value: $0.watts, fetching: $0.fetching) } + } + + /// What a finger says, per slot, in the live trace's words. The hero: + /// the node's own mean and "14:30–15:00 · charging". The line under the + /// chart: the energy so far, then each other series' mean. Two fingers: + /// their mean in the hero, the energy between under the chart. A + /// hidden series changes nothing the others say. + private func words(_ picture: HistoryPicture?) { + guard let picture, let own = picture.layers.first else { return text.set([], spans: { _ in "" }) } + let node = node + let others = picture.layers.dropFirst().compactMap { l in EnergyNode(rawValue: l.key).map { ($0, l.series) } } + let readouts = own.series.readouts + let buckets = own.series.buckets + let slots = readouts.when.indices.map { i -> String in + var parts = [NodeLive.soFar(node, readouts.soFar(i))] + for (n, s) in others where s.readouts.what.indices.contains(i) { + parts.append(n.title + " " + (s.readouts.what[i].components(separatedBy: " · ").first ?? "")) + } + return parts.joined(separator: " · ") + } + let heroes = buckets.indices.map { i -> PeriodText.Hero in + let when = readouts.when[i] + guard let m = buckets[i].mean else { + return PeriodText.Hero(watts: nil, signed: 0, word: when + " · " + (buckets[i].fetching ? L("FETCHING…").lowercased() : NodeWords.text("no data"))) + } + return PeriodText.Hero(watts: abs(m), signed: m, word: when + " · " + Readouts.word(node, m)) + } + func span(_ r: ClosedRange) -> String { + (readouts.when[r.lowerBound].components(separatedBy: "–").first ?? "") + "–" + (readouts.when[r.upperBound].components(separatedBy: "–").last ?? "") + } + text.set(slots, spans: { r in NodeLive.between(node, readouts.between(r.lowerBound, r.upperBound)) }, heroes: heroes, heroSpans: { r in + let means = buckets[r].compactMap(\.mean) + guard !means.isEmpty else { return PeriodText.Hero(watts: nil, signed: 0, word: span(r) + " · " + NodeWords.text("no data")) } + let mean = means.reduce(0, +) / Double(means.count) + return PeriodText.Hero(watts: abs(mean), signed: mean, word: span(r) + " · " + NodeWords.format("average %@", AppWords.power(abs(mean)))) + }) + } +} + +/// A history as VoiceOver reads it: the chart's own slot means, in the +/// slot's length. +private struct PictureDescriptor: AXChartDescriptorRepresentable { + let node: EnergyNode + let picture: HistoryPicture? + + func makeChartDescriptor() -> AXChartDescriptor { + let buckets = picture?.layers.first?.series.buckets ?? [] + let start = buckets.first?.startMs ?? 0, end = max(start + 1, buckets.last?.endMs ?? 1) + let values = buckets.compactMap(\.mean) + let lo = min(0, values.min() ?? 0), hi = max(lo + 1, values.max() ?? 1) + let x = AXNumericDataAxisDescriptor(title: L("Time"), range: start...end, gridlinePositions: []) { Readouts.clock($0) } + let y = AXNumericDataAxisDescriptor(title: L("Power"), range: lo...hi, gridlinePositions: []) { AppWords.power(abs($0)) } + let series = AXDataSeriesDescriptor(name: node.title, isContinuous: true, dataPoints: buckets.compactMap { b in + b.mean.map { AXDataPoint(x: b.startMs, y: $0, additionalValues: [], label: Readouts.word(node, $0)) } + }) + let minutes = Int(((picture?.stepMs ?? 1_800_000) / 60_000).rounded()) + return AXChartDescriptor(title: node.title, summary: NodeWords.format("%@-minute means", String(minutes)), xAxis: x, yAxis: y, additionalAxes: [], series: [series]) + } +} + +// MARK: - Ahead + +/// The hours ahead on the engine: the plan's quarters as ghost bars for the +/// battery, the grid and the car; the forecast as a dashed line for the sun +/// and the home; the price under the same slots. The finger lights a plan +/// slot as its whole bar. +struct AheadChartArea: View { + let site: SiteModel + let node: EnergyNode + let home: HomeModels + let scrub: ScrubModel + let text: PeriodText + @State private var size: CGSize = .zero + @ScaledMetric(relativeTo: .caption2) private var axis: CGFloat = 10 + @ScaledMetric(relativeTo: .caption) private var tag: CGFloat = 11 + + var body: some View { + let strip: CGFloat = 18 + let frame = ChartFrame(top: tag * 1.35 + 8, bottom: 6 + strip + axis * 1.4 + 4, trailing: axis * 3.2) + let made = build(frame.plot(in: size).size) + ZStack { + Color.clear + if let made { + let picture = made.picture, bars = made.bars, prices = made.prices, currency = made.currency, forecast = made.forecast + let look = NodeChartLook.look(node) + SlidingSurface(picture: picture) { from, slide, spring in + AheadDrawing(picture: picture, look: look, frame: frame, axis: axis, nowWord: NodeWords.text("NOW"), strip: strip, from: from, slide: slide, spring: spring) + } + ScrubOverlay( + track: ScrubTrack( + grid: picture.grid, + domain: picture.domain, + values: picture.values, + tags: picture.grid.count == 0 ? [] : (0.. Built? { + guard plot.width > 0, plot.height > 0 else { return nil } + let now = site.minuteMs + let plan = home.nodes.plan + let window = home.prices.prices + let currency = window?.currency ?? "SEK" + var bars: [ChartSlot] = [] + var forecast = false + switch node { + case .battery, .grid: + bars = plan.ahead(nowMs: now).map { ChartSlot(startMs: $0.startMs, endMs: $0.endMs, watts: node == .battery ? $0.batteryW : $0.gridW, reason: $0.reason) } + case .solar, .home: + forecast = true + bars = plan.forecastAhead(nowMs: now).map { ChartSlot(startMs: $0.startMs, endMs: $0.startMs + $0.durationMs, watts: node == .solar ? $0.pvW : $0.loadW) } + case .car: + let points = home.loadpoints.points + for lp in points { + for w in home.loadpoints.windows[lp.id] ?? lp.planWindows where w.toMs > now { + // A window that names no peak is drawn at its mean. + let mean: Double = (w.energyWh ?? 0) * 3_600_000 / max(1, w.toMs - w.fromMs) + bars.append(ChartSlot(startMs: w.fromMs, endMs: w.toMs, watts: w.peakW ?? mean)) + } + } + } + guard !bars.isEmpty || node == .car else { return nil } + let prices = (window?.slots ?? []).map { ChartPrice(startMs: $0.startMs, endMs: $0.startMs + $0.durationMs, minor: $0.totalMinor) } + let slots: [(startMs: Double, endMs: Double, watts: Double?)] = bars.map { ($0.startMs, $0.endMs, $0.watts) } + let rates: [(startMs: Double, endMs: Double, minor: Double)] = prices.map { ($0.startMs, $0.endMs, $0.minor) } + let picture = AheadPicture.build( + kind: forecast ? .forecast : .plan, + slots: slots, + prices: rates, + nowMs: now, + spanMs: PlanModel.aheadMs, + size: plot, + floor: node.scaleFloorW(), + clock: ZoneClock.of(.current) + ) + return Built(picture: picture, bars: bars, prices: prices, currency: currency, forecast: forecast) + } +} diff --git a/appleApp/FTW/UI/NodeView.swift b/appleApp/FTW/UI/NodeView.swift index 9b7ec95..ebcc671 100644 --- a/appleApp/FTW/UI/NodeView.swift +++ b/appleApp/FTW/UI/NodeView.swift @@ -28,7 +28,10 @@ struct NodeView: View { @State private var trace = NodeTraceModel() @State private var choosingMode = false @State private var timebase: Timebase - @State private var readout: ChartReadout? + @State private var text = PeriodText() + @State private var series: [EnergyNode] + @State private var enlarged = false + @Namespace private var chartZoom @Environment(\.dynamicTypeSize) private var typeSize init(app: AppModel, home: HomeModels, node: EnergyNode, zoom: Namespace.ID) { @@ -37,6 +40,7 @@ struct NodeView: View { self.node = node self.zoom = zoom _timebase = State(initialValue: Timebase.remembered(node)) + _series = State(initialValue: SeriesChoice.load(node)) } /// Live, today, the last two days and the hours ahead: one chart, one @@ -101,7 +105,6 @@ struct NodeView: View { } .onChange(of: timebase) { _, now in UserDefaults.standard.set(now.rawValue, forKey: Timebase.key(node)) - readout = nil scrub.lift() } .onChange(of: scrub.isDown) { _, down in @@ -110,20 +113,52 @@ struct NodeView: View { .sheet(isPresented: $choosingMode) { ModeSheet(mode: models.mode) } + .fullScreenCover(isPresented: $enlarged) { + ChartFullScreen(site: site, node: node, home: home, timebase: $timebase, series: $series, timebases: timebases) + .navigationTransition(.zoom(sourceID: "chart", in: chartZoom)) + } } + private var timebases: [Timebase] { Timebase.allCases.filter { $0 != .ahead || hasAhead } } + private func page(fill: Bool) -> some View { - let timebases = Timebase.allCases.filter { $0 != .ahead || hasAhead } return VStack(alignment: .leading, spacing: 0) { - NodeHero(site: site, node: node, home: home, trace: trace, scrub: scrub, timebase: timebase) - .padding(.horizontal, 20) - NodeStatusRow(site: site, node: node) + NodeHero(site: site, node: node, home: home, trace: trace, scrub: scrub, timebase: timebase, text: text) + .dynamicTypeSize(...DynamicTypeSize.accessibility1) .padding(.horizontal, 20) - .padding(.top, 20) - NodeChart(site: site, node: node, home: home, trace: trace, scrub: scrub, timebase: timebase, readout: $readout) - .frame(minHeight: fill ? 180 : 240, idealHeight: 300, maxHeight: 340) - .padding(.top, 8) + HStack(spacing: 8) { + NodeStatusRow(site: site, node: node) + .dynamicTypeSize(...DynamicTypeSize.xxLarge) + Spacer(minLength: 4) + Button { enlarged = true } label: { + Image(systemName: "arrow.up.left.and.arrow.down.right") + .font(.footnote.weight(.semibold)) + .foregroundStyle(Theme.ink3) + .frame(minWidth: 32, minHeight: 28) + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + .accessibilityLabel(L("Enlarge the chart")) + .accessibilityIdentifier("node.chart.enlarge") + } + .padding(.horizontal, 20) + .padding(.top, 16) + // The series a history shows; the row keeps its room on every + // timebase, so nothing moves on a switch. + SeriesChips(own: node, available: Self.chartable(node), chosen: $series) + .dynamicTypeSize(...DynamicTypeSize.xxLarge) + .padding(.horizontal, 14) + .opacity(timebase == .today || timebase == .twoDays ? 1 : 0) + .allowsHitTesting(timebase == .today || timebase == .twoDays) + .accessibilityHidden(!(timebase == .today || timebase == .twoDays)) + // Compact: the view never scrolls for it. A tap opens it on the + // whole screen, growing out of this one. + NodeChart(site: site, node: node, home: home, trace: trace, scrub: scrub, timebase: timebase, series: series, text: text) + .frame(minHeight: fill ? 150 : 200, idealHeight: 200, maxHeight: 230) .padding(.horizontal, 4) + .contentShape(Rectangle()) + .simultaneousGesture(TapGesture().onEnded { enlarged = true }) + .matchedTransitionSource(id: "chart", in: chartZoom) Picker(L("Period"), selection: $timebase.animation(.easeInOut(duration: 0.2))) { ForEach(timebases, id: \.self) { Text($0.label).tag($0) } } @@ -131,7 +166,7 @@ struct NodeView: View { .accessibilityIdentifier("node.period") .padding(.horizontal, 20) .padding(.top, 12) - RangeLine(site: site, node: node, home: home, trace: trace, scrub: scrub, timebase: timebase, readout: readout) + RangeLine(site: site, node: node, home: home, trace: trace, scrub: scrub, timebase: timebase, text: text) .padding(.horizontal, 20) .padding(.top, 12) if node == .battery { @@ -145,6 +180,7 @@ struct NodeView: View { } if fill { Spacer(minLength: 8) } NodeFooter(site: site, node: node, live: models.live) + .dynamicTypeSize(...DynamicTypeSize.xxLarge) .padding(.horizontal, 20) .padding(.top, fill ? 0 : 16) .padding(.bottom, 8) @@ -154,6 +190,12 @@ struct NodeView: View { .padding(.top, 8) } + /// The series a node's history can show: the box's tiles, and the car + /// on its own view, from its hours. + static func chartable(_ node: EnergyNode) -> [EnergyNode] { + EnergyNode.allCases.filter { $0 != .car || node == .car } + } + /// Ahead shows only where the box plans or forecasts for this node. private var hasAhead: Bool { switch node { @@ -239,6 +281,7 @@ private struct NodeHero: View { let trace: NodeTraceModel let scrub: ScrubModel let timebase: NodeView.Timebase + let text: PeriodText @ScaledMetric(relativeTo: .largeTitle) private var figureSize: CGFloat = 60 @ScaledMetric(relativeTo: .title2) private var unitSize: CGFloat = 22 @@ -253,8 +296,15 @@ private struct NodeHero: View { hasCar: !loadpoints.points.isEmpty ).first { $0.node == node } let live = reading?.freshness.live ?? false - let under = timebase == .live ? fingered : nil - let watts = under?.watts ?? reading?.watts + // Under a finger the hero says what the finger is on, on Live and + // on the measured past alike. The hours ahead are a plan, not a + // reading, so there the hero stays on now. + let under: (watts: Double?, signed: Double, word: String)? = switch timebase { + case .live: fingered.map { ($0.watts, $0.signed, $0.word) } + case .today, .twoDays: text.hero(scrub).map { ($0.watts, $0.signed, $0.word) } + case .ahead: nil + } + let watts = under.map(\.watts) ?? reading?.watts let word = under?.word ?? reading?.word ?? L("Your box sends no reading for this.") let tone = under.map { Readouts.tone(node, $0.signed) } ?? reading?.tone ?? .muted @@ -274,7 +324,7 @@ private struct NodeHero: View { SecondFact(site: site, node: node, home: home, reading: reading, live: live) } .transaction { $0.animation = scrub.isDown ? nil : Theme.spring } - .dynamicTypeSize(...DynamicTypeSize.accessibility1) + .dynamicTypeSize(...DynamicTypeSize.xxLarge) .accessibilityElement(children: .combine) .accessibilityIdentifier("node.hero") } @@ -424,16 +474,17 @@ private struct NodeStatusRow: View { .tracking(0.5) .lineLimit(1) .minimumScaleFactor(0.75) - .dynamicTypeSize(...DynamicTypeSize.accessibility1) + .dynamicTypeSize(...DynamicTypeSize.xxLarge) .accessibilityIdentifier("node.status") .contentTransition(.opacity) .animation(.default, value: text) } } -/// One place for the node's chart. Live streams the trace; today, the -/// last two days and the hours ahead draw their own charts in the same -/// frame. A switch cross-fades in 0.2 s and the scale does not morph. +/// One place for the node's chart. Live streams the trace; today and the +/// last two days draw the chosen series on the engine; the hours ahead +/// draw the plan or the forecast with the price. A switch cross-fades in +/// 0.2 s and the scale does not morph. private struct NodeChart: View { let site: SiteModel let node: EnergyNode @@ -441,7 +492,8 @@ private struct NodeChart: View { let trace: NodeTraceModel let scrub: ScrubModel let timebase: NodeView.Timebase - @Binding var readout: ChartReadout? + let series: [EnergyNode] + let text: PeriodText var body: some View { ZStack { @@ -449,13 +501,95 @@ private struct NodeChart: View { case .live: LiveChartArea(site: site, node: node, live: home.nodes.live, trace: trace, scrub: scrub) .transition(.opacity) - default: - PeriodChart(site: site, node: node, period: timebase, models: home.nodes, prices: home.prices, loadpoints: home.loadpoints, onScrub: { readout = $0 }) - .padding(.horizontal, 12) + case .today, .twoDays: + HistoryChartArea(site: site, node: node, home: home, scrub: scrub, period: timebase, series: series, text: text) + .transition(.opacity) + .accessibilityIdentifier("node.period.chart") + case .ahead: + AheadChartArea(site: site, node: node, home: home, scrub: scrub, text: text) .transition(.opacity) .accessibilityIdentifier("node.period.chart") } } + // A chart's words stop growing at xxLarge: they are drawn in the + // Canvas, and said again under the chart and to VoiceOver. Set here, + // above the charts, so their scaled sizes read it. + .dynamicTypeSize(...DynamicTypeSize.xxLarge) + } +} + +/// The chart on the whole screen, grown out of the small one: the same +/// timebases, series and finger, with room to read. +private struct ChartFullScreen: View { + let site: SiteModel + let node: EnergyNode + let home: HomeModels + @Binding var timebase: NodeView.Timebase + @Binding var series: [EnergyNode] + let timebases: [NodeView.Timebase] + @State private var scrub = ScrubModel() + @State private var trace = NodeTraceModel() + @State private var text = PeriodText() + @Environment(\.dismiss) private var dismiss + @Environment(\.dynamicTypeSize) private var typeSize + + var body: some View { + NavigationStack { + Group { + // The accessibility sizes scroll, with the chart at a fixed + // height; every other size is one page. + if typeSize.isAccessibilitySize { + ScrollView { content(chart: 360) } + } else { + content(chart: nil) + } + } + .background(Theme.ground) + .navigationTitle(node.title) + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .topBarTrailing) { + Button { dismiss() } label: { Image(systemName: "xmark") } + .accessibilityLabel(L("Close")) + .accessibilityIdentifier("node.chart.close") + } + } + } + .onChange(of: timebase) { _, _ in scrub.lift() } + .onChange(of: scrub.isDown) { _, down in + if down { trace.hold(atMs: Date().timeIntervalSince1970 * 1000) } else { trace.release() } + } + } + + private func content(chart height: CGFloat?) -> some View { + VStack(alignment: .leading, spacing: 0) { + NodeHero(site: site, node: node, home: home, trace: trace, scrub: scrub, timebase: timebase, text: text) + .dynamicTypeSize(...DynamicTypeSize.accessibility1) + .padding(.horizontal, 20) + NodeStatusRow(site: site, node: node) + .dynamicTypeSize(...DynamicTypeSize.xxLarge) + .padding(.horizontal, 20) + .padding(.top, 12) + SeriesChips(own: node, available: NodeView.chartable(node), chosen: $series) + .dynamicTypeSize(...DynamicTypeSize.xxLarge) + .padding(.horizontal, 14) + .opacity(timebase == .today || timebase == .twoDays ? 1 : 0) + .allowsHitTesting(timebase == .today || timebase == .twoDays) + NodeChart(site: site, node: node, home: home, trace: trace, scrub: scrub, timebase: timebase, series: series, text: text) + .frame(height: height) + .frame(maxHeight: height == nil ? .infinity : nil) + .padding(.horizontal, 4) + Picker(L("Period"), selection: $timebase.animation(.easeInOut(duration: 0.2))) { + ForEach(timebases, id: \.self) { Text($0.label).tag($0) } + } + .pickerStyle(.segmented) + .accessibilityIdentifier("node.chart.period") + .padding(.horizontal, 20) + .padding(.top, 12) + RangeLine(site: site, node: node, home: home, trace: trace, scrub: scrub, timebase: timebase, text: text) + .padding(.horizontal, 20) + .padding(.vertical, 12) + } } } @@ -475,7 +609,8 @@ private struct LiveChartArea: View { var body: some View { let revision = live.revision let chartFrame = ChartFrame(top: tag * 1.35 + 8, bottom: 6, trailing: axis * 3.2) - let look = Self.look(node) + // The same colours as every other chart of this node. + let look = NodeChartLook.look(node) let isLive = site.freshness(node).live ZStack { // Takes the whole frame before the first reading, so the @@ -499,7 +634,7 @@ private struct LiveChartArea: View { ) } } - .dynamicTypeSize(...DynamicTypeSize.accessibility1) + .dynamicTypeSize(...DynamicTypeSize.xxLarge) .onGeometryChange(for: CGSize.self) { $0.size } action: { size = $0 } .onChange(of: revision, initial: true) { rebuild(chartFrame) } .onChange(of: size) { rebuild(chartFrame) } @@ -552,18 +687,6 @@ private struct LiveChartArea: View { trace.build(live.points(node.fid, sinceMs: Date().timeIntervalSince1970 * 1000 - LiveTrace.windowMs - 10_000), node: node, carrier: site.carrier, size: plot) } - /// Each side of zero in its direction's colour: the battery cyan - /// storing and gold giving, the grid magenta both ways. - static func look(_ node: EnergyNode) -> TraceLook { - let words = node.directions - switch node { - case .battery: return TraceLook(up: Theme.stores, down: Theme.gives, upWord: words.map { "↑ " + $0.up.uppercased() }, downWord: words.map { "↓ " + $0.down.uppercased() }) - case .grid: return TraceLook(up: Theme.grid, down: Theme.grid, upWord: words.map { "↑ " + $0.up.uppercased() }, downWord: words.map { "↓ " + $0.down.uppercased() }) - default: - let c = Theme.color(NodeLook.tone(node)) - return TraceLook(up: c, down: c) - } - } } /// The trace as VoiceOver reads it: every reading, in watts. @@ -595,12 +718,12 @@ private struct RangeLine: View { let trace: NodeTraceModel let scrub: ScrubModel let timebase: NodeView.Timebase - let readout: ChartReadout? + let text: PeriodText var body: some View { ZStack(alignment: .topLeading) { Text(verbatim: "\n") - Text(text) + Text(line) .accessibilityIdentifier("chart.readout") } .font(.subheadline) @@ -610,9 +733,9 @@ private struct RangeLine: View { .transaction { $0.animation = nil } } - private var text: String { + private var line: String { guard timebase == .live else { - if let readout { return readout.when + " " + readout.what } + if let under = text.text(scrub) { return under } return PeriodWords.summary(site: site, node: node, period: timebase, models: home.nodes, loadpoints: home.loadpoints) } guard let frame = trace.shown else { return " " } @@ -649,7 +772,7 @@ private struct NodeFooter: View { .foregroundStyle(Theme.ink3) .lineLimit(2) .minimumScaleFactor(0.75) - .dynamicTypeSize(...DynamicTypeSize.accessibility1) + .dynamicTypeSize(...DynamicTypeSize.xxLarge) .accessibilityIdentifier("node.footer") } } @@ -731,110 +854,6 @@ private struct CarRow: View { } } -// MARK: - Today, two days, ahead - -/// Today, the last two days or the hours ahead, in the live chart's frame. -/// These keep their own charts until WP7 moves them onto the engine. -private struct PeriodChart: View { - let site: SiteModel - let node: EnergyNode - let period: NodeView.Timebase - let models: NodeModels - let prices: PriceModel - let loadpoints: LoadpointsModel - let onScrub: (ChartReadout?) -> Void - - var body: some View { - Group { - switch period { - case .ahead: ahead - default: past - } - } - .frame(maxWidth: .infinity, maxHeight: .infinity) - } - - private var fromMs: Double { - period == .today ? models.history.midnightMs : site.nowMs - HistoryModel.spanMs - } - - /// The hour the history is drawn to: the tiles end at the last five - /// minutes, and the axis at now. - private var toMs: Double { (site.nowMs / 60_000).rounded(.up) * 60_000 } - - @ViewBuilder private var past: some View { - let stride = period == .today ? 6 : 12 - if node == .car { - if models.carEnergy.loaded { - CarHoursChart(hours: models.carEnergy.hours, fromMs: fromMs, toMs: toMs, strideHours: stride, onScrub: onScrub).equatable() - } else { - Placeholder(text: models.carEnergy.problem) - } - } else if !models.history.available { - Placeholder(text: L("Your box keeps no history.")) - } else if models.history.frame != nil { - HistoryChart(node: node, samples: models.history.samples(node, fromMs: fromMs), fromMs: fromMs, toMs: toMs, look: NodeLook.look(node), strideHours: stride, onScrub: onScrub) - .equatable() - } else { - Placeholder(text: models.history.problem) - } - } - - @ViewBuilder private var ahead: some View { - let now = site.nowMs - let window = prices.prices - let priceBars = (window?.slots ?? []).map { AheadChart.Price(startMs: $0.startMs, endMs: $0.startMs + $0.durationMs, minor: $0.totalMinor) } - switch node { - case .battery, .grid: - let slots = models.plan.ahead(nowMs: now) - if slots.isEmpty { - Placeholder(text: models.plan.loaded ? nil : models.plan.problem) - } else { - AheadChart( - node: node, - bars: slots.map { AheadChart.Bar(startMs: $0.startMs, endMs: $0.endMs, watts: node == .battery ? $0.batteryW : $0.gridW, reason: $0.reason) }, - prices: slots.compactMap { s in s.priceMinor.map { AheadChart.Price(startMs: s.startMs, endMs: s.endMs, minor: $0) } }, - currency: window?.currency ?? "SEK", - nowMs: now, - look: NodeLook.look(node), - onScrub: onScrub - ).equatable() - } - case .solar, .home: - let slots = models.plan.forecastAhead(nowMs: now) - if slots.isEmpty { - Placeholder(text: models.plan.forecastLoaded ? L("No forecast for the hours ahead yet.") : nil) - } else { - AheadChart( - node: node, - bars: slots.map { AheadChart.Bar(startMs: $0.startMs, endMs: $0.startMs + $0.durationMs, watts: node == .solar ? $0.pvW : $0.loadW) }, - prices: slots.compactMap { s in s.priceMinor.map { AheadChart.Price(startMs: s.startMs, endMs: s.startMs + s.durationMs, minor: $0) } }, - currency: window?.currency ?? "SEK", - nowMs: now, - look: NodeLook.look(node), - forecast: true, - onScrub: onScrub - ).equatable() - } - case .car: - let windows = loadpoints.points.flatMap { loadpoints.windows[$0.id] ?? $0.planWindows } - AheadChart( - node: .car, - bars: windows.filter { $0.toMs > now }.map { w in - // A window that names no peak is drawn at its mean. - AheadChart.Bar(startMs: w.fromMs, endMs: w.toMs, watts: w.peakW ?? (w.energyWh ?? 0) * 3_600_000 / max(1, w.toMs - w.fromMs)) - }, - prices: priceBars, - currency: window?.currency ?? "SEK", - nowMs: now, - look: NodeLook.look(.car), - onScrub: onScrub - ).equatable() - } - } - -} - /// The line under today, the last two days and the hours ahead. private enum PeriodWords { @MainActor @@ -875,25 +894,6 @@ private enum PeriodWords { } } -/// The shape of a chart that has not arrived, with what is happening. -private struct Placeholder: View { - let text: String? - - var body: some View { - VStack(spacing: 6) { - if let text { - Text(text).font(.footnote).foregroundStyle(Theme.ink3).multilineTextAlignment(.center) - } else { - RoundedRectangle(cornerRadius: 6) - .fill(Theme.hairline) - .redacted(reason: .placeholder) - } - } - .frame(maxWidth: .infinity, maxHeight: .infinity) - } -} - - /// The energy line under a node's history, in the node's own words. enum NodeSummary { static func energy(_ node: EnergyNode, _ e: EnergyToday, today: Bool) -> String { @@ -913,36 +913,3 @@ enum NodeSummary { return text } } - -extension NodeLook { - /// The colour of a node's curve. - static func tone(_ node: EnergyNode) -> Flow.Tone { - switch node { - case .solar: return .solar - case .battery: return .battery - case .home: return .house - case .grid: return .importing - case .car: return .ev - } - } - - /// One colour pair per node for all its charts: the battery in its own - /// colour, giving power in the same colour lighter; the grid red when - /// buying and green when selling, as on Now. Grey when not live. - static func look(_ node: EnergyNode, live: Bool = true) -> ChartLook { - let words = node.directions - guard live else { - return ChartLook(up: Theme.ink3, down: Theme.ink3.opacity(0.6), line: Theme.ink3, upWord: words?.up, downWord: words?.down) - } - switch node { - case .battery: - return ChartLook(up: Theme.stores, down: Theme.gives, line: Theme.ink2, upWord: words?.up, downWord: words?.down) - case .grid: - return ChartLook(up: Theme.grid, down: Theme.grid, line: Theme.grid, upWord: words?.up, downWord: words?.down) - default: - let c = Theme.color(tone(node)) - return ChartLook(up: c, down: c, line: c) - } - } - -} diff --git a/appleApp/FTW/UI/PlanCard.swift b/appleApp/FTW/UI/PlanCard.swift index b027969..e9575ab 100644 --- a/appleApp/FTW/UI/PlanCard.swift +++ b/appleApp/FTW/UI/PlanCard.swift @@ -148,3 +148,11 @@ struct PlanLanes: View, Equatable { private func date(_ ms: Double) -> Date { Date(timeIntervalSince1970: ms / 1000) } } + +/// "15", on the 24-hour clock FTW uses in both languages: an English phone +/// would otherwise label the afternoon "03". +private func hourLabel(_ value: AxisValue) -> some View { + Text(value.as(Date.self).map { String(format: "%02d", Calendar.current.component(.hour, from: $0)) } ?? "") + .font(.caption2) + .foregroundStyle(Theme.ink3) +} diff --git a/appleApp/FTW/UI/PriceView.swift b/appleApp/FTW/UI/PriceView.swift index d6418e7..5bb91dd 100644 --- a/appleApp/FTW/UI/PriceView.swift +++ b/appleApp/FTW/UI/PriceView.swift @@ -84,6 +84,7 @@ private struct PricePage: View, Equatable { let included = PriceGlance.text(PriceGlance.included(window.slots)) VStack(alignment: .leading, spacing: 0) { PriceHero(chart: chart, zone: window.zone, scrub: scrub) + .dynamicTypeSize(...DynamicTypeSize.accessibility1) if chart.isEmpty { Text(L("No prices ahead yet.")) .font(.subheadline) diff --git a/appleApp/FTWCharts/HistoryCharts.swift b/appleApp/FTWCharts/HistoryCharts.swift new file mode 100644 index 0000000..b6ac58f --- /dev/null +++ b/appleApp/FTWCharts/HistoryCharts.swift @@ -0,0 +1,312 @@ +import FTWShared +import SwiftUI + +/// The fixed words a history or ahead chart draws, worded by the app. +struct ChartLabels: Equatable { + /// "FETCHING…", over a hole on its way. + var fetching = "FETCHING…" + /// "AS OF 15:30", where the newest data ends when it is old. + var asOf: String? + /// "NOW", under the now rule. + var now = "NOW" +} + +/// Today or the last two days, in one Canvas, in the live trace's +/// material (`TraceInk`): per series its band of the five-minute means, +/// the chart's own series' body, and the line through the exact slot +/// means. Glow means live, so only the slot still filling glows, and only +/// while the node is live. A hole on its way is a hatch with a dashed +/// estimate across it; a hole the box has no record for is a dotted +/// baseline. Axes are drawn here too, so they never reflow: zero as one +/// device pixel, the scale's ends in the right gutter, the direction words +/// when both signs show, the hours, and NOW. +/// +/// When a tile lands in the same window, each slot slides from its old +/// mean to its new one (`from`, `slide`), as a new reading slides into the +/// live trace; a new scale and a series coming or going move as one spring +/// (`spring`). A new window draws at once, on the scale's spring. +struct HistoryDrawing: ChartDrawing { + let picture: HistoryPicture + /// The colours per series key, each side of zero. + let looks: [String: TraceLook] + let frame: ChartFrame + var words = ChartLabels() + var axis: CGFloat = 10 + /// Whether the node's reading is live now: the slot still filling glows. + var live = false + var from: HistoryPicture? + var slide: Double = 1 + var spring: Double = 1 + + func draw(in g: inout GraphicsContext, size: CGSize) { + let plot = frame.plot(in: size) + let px = 1 / max(g.environment.displayScale, 1) + let primary = picture.layers.first?.key + + let k = min(max(spring, 0), 1) + let shown = from.map { TraceInk.between($0.domain, picture.domain, k) } ?? picture.domain + let toShown = TraceInk.rescale(picture.domain, shown, height: plot.height) + let base = YMap(domain: shown, height: plot.height).baseline + + ChartAxes.draw(in: &g, plot: plot, domain: picture.domain, px: px, axis: axis, look: primary.flatMap { looks[$0] }, shown: shown) + + var p = g + p.clip(to: Path(CGRect(x: plot.minX, y: plot.minY - 12, width: plot.width, height: plot.height + 24))) + p.translateBy(x: plot.minX, y: plot.minY) + let above = Path(CGRect(x: -10, y: -40, width: plot.width + 20, height: base + 40)) + let below = Path(CGRect(x: -10, y: base, width: plot.width + 20, height: plot.height - base + 40)) + + // The slot still filling, the only part of the day that is live. + var filling: Path? + if TraceInk.glows(live: live, in: g), let now = picture.nowMs, let i = picture.grid.index(atMs: now) { + let x = picture.grid.x(atMs: max(picture.grid.startMs(i), picture.grid.fromMs), width: plot.width) + filling = Path(CGRect(x: x, y: -40, width: plot.width - x + 20, height: plot.height + 80)) + } + + // A series taken away, fading out on the moving scale. + if let from, k < 1 { + for gone in from.layers where !picture.layers.contains(where: { $0.key == gone.key }) { + let look = looks[gone.key] ?? TraceLook(up: Theme.ink2, down: Theme.ink2) + var s = p + s.opacity = 1 - k + TraceInk.line(Path(gone.series.line.cgPath).applying(TraceInk.rescale(from.domain, shown, height: plot.height)), look: look, width: TraceInk.otherLine, glows: false, above: above, below: below, in: s) + } + } + + // The others first, the chart's own series last, on top. + for layer in picture.layers.reversed() { + let look = looks[layer.key] ?? TraceLook(up: Theme.ink, down: Theme.ink) + let series = layer.series + let own = layer.key == primary + var line = Path(series.line.cgPath), body = Path(series.body.cgPath), band = Path(series.band.cgPath) + let before = from?.layers.first(where: { $0.key == layer.key })?.series + if let before, let mixed = series.blended(from: before, progress: slide) { + line = Path(mixed.line.cgPath) + body = Path(mixed.body.cgPath) + band = Path(mixed.band.cgPath) + } + if toShown != .identity { + line = line.applying(toShown) + body = body.applying(toShown) + band = band.applying(toShown) + } + var p = p + // A series just added fades in. + if from != nil, before == nil, k < 1 { p.opacity = k } + TraceInk.band(band, look: look, above: above, below: below, in: p) + if own { + TraceInk.body(body, look: look, baseline: base, height: plot.height, above: above, below: below, in: p) + } + // Holes: a hatch on its way with a dashed estimate, a dotted + // baseline where the box has no record. + if own { + for r in series.fetching { + let x0 = series.grid.x(atMs: series.grid.startMs(r.lowerBound), width: plot.width) + let x1 = series.grid.x(atMs: series.grid.endMs(r.upperBound), width: plot.width) + let rect = CGRect(x: x0, y: 0, width: max(px, x1 - x0), height: plot.height) + var hatch = p + hatch.clip(to: Path(rect)) + hatch.fill(Path(rect), with: .color(Theme.ink.opacity(0.04))) + hatch.stroke(Self.hatch(rect), with: .color(Theme.ink.opacity(0.10)), style: StrokeStyle(lineWidth: px, lineCap: .butt, lineJoin: .round)) + let text = p.resolve(ChartText.label(words.fetching, size: axis)) + p.draw(text, at: CGPoint(x: rect.midX, y: 4), anchor: .top) + } + var dotted = Path() + for r in series.missing { + dotted.move(to: CGPoint(x: series.grid.x(atMs: series.grid.startMs(r.lowerBound), width: plot.width), y: base)) + dotted.addLine(to: CGPoint(x: series.grid.x(atMs: series.grid.endMs(r.upperBound), width: plot.width), y: base)) + } + p.stroke(dotted, with: .color(Theme.ink.opacity(0.35)), style: StrokeStyle(lineWidth: px, lineCap: .butt, lineJoin: .round, dash: [1, 3])) + } + p.stroke(Path(series.estimate.cgPath).applying(toShown), with: .color(look.up.opacity(0.6)), style: StrokeStyle(lineWidth: 1.75, lineCap: .round, lineJoin: .round, dash: [3, 4])) + TraceInk.line(line, look: look, width: own ? TraceInk.line : TraceInk.otherLine, opacity: own ? 1 : 0.85, glows: own && filling != nil, glowClip: filling, above: above, below: below, in: p) + } + + // Now, and where old data ends. + if let now = picture.nowMs, now > picture.grid.fromMs, now < picture.grid.toMs { + let x = plot.minX + (picture.grid.x(atMs: now, width: plot.width) / px).rounded() * px + px / 2 + var rule = Path() + rule.move(to: CGPoint(x: x, y: plot.minY)) + rule.addLine(to: CGPoint(x: x, y: plot.maxY)) + g.stroke(rule, with: .color(Theme.ink.opacity(0.5)), style: StrokeStyle(lineWidth: px, lineCap: .butt, lineJoin: .round)) + } + if let asOf = picture.asOfMs, let text = words.asOf { + let x = plot.minX + picture.grid.x(atMs: asOf, width: plot.width) + let label = g.resolve(ChartText.label(text, size: axis)) + let w = label.measure(in: size).width + g.draw(label, at: CGPoint(x: min(max(plot.minX, x - w / 2), plot.maxX - w), y: plot.minY + 2), anchor: .topLeading) + } + ChartAxes.time(in: &g, plot: plot, size: size, grid: picture.grid, ticks: picture.ticks.map { ($0.ms, $0.label) }, nowMs: picture.nowMs, nowWord: words.now, axis: axis) + } + + /// Diagonal hairlines 4 pt apart across a rect. + static func hatch(_ rect: CGRect) -> Path { + var path = Path() + var x = rect.minX - rect.height + while x < rect.maxX { + path.move(to: CGPoint(x: x, y: rect.maxY)) + path.addLine(to: CGPoint(x: x + rect.height, y: rect.minY)) + x += 4 + } + return path + } +} + +/// The hours ahead, in one Canvas: a plan's quarters as ghost bars (a +/// faint fill with a 1 px cap, equal neighbours merged), or a forecast's +/// dashed line, and the price under the same slots as block bars in ink by +/// thirds. The now rule sits at the left edge. A new plan or forecast for +/// the same slots slides each slot to its new value, and a new scale +/// springs, as on the live trace. +struct AheadDrawing: ChartDrawing { + let picture: AheadPicture + let look: TraceLook + let frame: ChartFrame + var axis: CGFloat = 10 + var nowWord = "NOW" + /// The price strip's height inside the frame's bottom. + var strip: CGFloat = 18 + var from: AheadPicture? + var slide: Double = 1 + var spring: Double = 1 + + func draw(in g: inout GraphicsContext, size: CGSize) { + let plot = frame.plot(in: size) + let scale = max(g.environment.displayScale, 1) + let px = 1 / scale + let shown = from.map { TraceInk.between($0.domain, picture.domain, min(max(spring, 0), 1)) } ?? picture.domain + let map = YMap(domain: shown, height: plot.height) + let mixed = from.flatMap { picture.blended(from: $0, progress: slide) } + let values = mixed?.values ?? picture.values + + ChartAxes.draw(in: &g, plot: plot, domain: picture.domain, px: px, axis: axis, look: look, shown: shown) + + var p = g + p.translateBy(x: plot.minX, y: plot.minY) + switch picture.kind { + case .plan: + for run in ChartPaths.runs(values, grid: picture.grid, width: plot.width, map: map, scale: scale, maxRadius: 4) { + let color = run.value >= 0 ? look.up : look.down + let r = run.rect + let radii = run.value > 0 + ? RectangleCornerRadii(topLeading: run.radius, bottomLeading: 0, bottomTrailing: 0, topTrailing: run.radius) + : RectangleCornerRadii(topLeading: 0, bottomLeading: run.radius, bottomTrailing: run.radius, topTrailing: 0) + p.fill(Path(roundedRect: r, cornerRadii: radii, style: .circular), with: .color(color.opacity(0.14))) + var cap = Path() + let y = run.value > 0 ? r.minY + px / 2 : r.maxY - px / 2 + cap.move(to: CGPoint(x: r.minX + run.radius, y: y)) + cap.addLine(to: CGPoint(x: r.maxX - run.radius, y: y)) + p.stroke(cap, with: .color(color.opacity(0.7)), style: StrokeStyle(lineWidth: px, lineCap: .round, lineJoin: .round)) + } + case .forecast: + // An estimate, so dashed, like the estimate across a hole. + let line = mixed == nil && shown == picture.domain ? picture.line : AheadPicture.forecastLine(values, grid: picture.grid, domain: shown, size: plot.size) + p.stroke(Path(line.cgPath), with: .color(look.up.opacity(0.5)), style: StrokeStyle(lineWidth: 1.75, lineCap: .round, lineJoin: .round, dash: [3, 4])) + } + + // The price strip: block bars in eight steps, ink by thirds. + let stripTop = plot.maxY + 6 + let w = plot.width / CGFloat(max(1, picture.grid.count)) + for i in 0.. 0, let look { + if let up = look.upWord { g.draw(g.resolve(ChartText.label(up, size: axis)), at: CGPoint(x: plot.minX + 2, y: plot.minY + 2), anchor: .topLeading) } + if let down = look.downWord { g.draw(g.resolve(ChartText.label(down, size: axis)), at: CGPoint(x: plot.minX + 2, y: plot.maxY - 2), anchor: .bottomLeading) } + } + } + + /// The hour labels on the bottom row, and NOW under the now rule; a + /// label that would collide with one already placed is left out. + static func time(in g: inout GraphicsContext, plot: CGRect, size: CGSize, grid: SlotGrid, ticks: [(Double, String)], nowMs: Double?, nowWord: String, axis: CGFloat) { + var taken: [ClosedRange] = [] + if let now = nowMs, now >= grid.fromMs, now < grid.toMs { + let label = g.resolve(ChartText.label(nowWord, size: axis, weight: .semibold, color: Theme.ink2)) + let w = label.measure(in: size).width + let x = plot.minX + grid.x(atMs: now, width: plot.width) + let left = min(max(plot.minX, x - w / 2), plot.maxX - w) + taken.append(left...(left + w)) + g.draw(label, at: CGPoint(x: left, y: size.height), anchor: .bottomLeading) + } + for (ms, text) in ticks { + let label = g.resolve(ChartText.label(text, size: axis)) + let w = label.measure(in: size).width + // Centred on its hour, but kept inside the chart at either end. + let x = min(max(plot.minX + grid.x(atMs: ms, width: plot.width), plot.minX + w / 2), size.width - w / 2) + let span = (x - w / 2 - 4)...(x + w / 2 + 4) + guard !taken.contains(where: { $0.overlaps(span) }) else { continue } + taken.append(span) + g.draw(label, at: CGPoint(x: x, y: size.height), anchor: .bottom) + } + } +} + +/// A chart whose data moves as the live trace's does: a change for the +/// same slots slides over `TraceInk.slide`, a new scale or a series coming +/// or going springs over `TraceInk.spring`, at the display's rate, and +/// then the chart holds still. A new window draws at once, on the scale's +/// spring. Data from the cache starts settled. +struct SlidingSurface: View { + let picture: Picture + /// The drawing for the picture on its way from `from`, with the slide's + /// and the spring's progress. + let drawing: (_ from: Picture?, _ slide: Double, _ spring: Double) -> Drawing + @State private var old: Picture? + @State private var changedAt = Date.distantPast + @Environment(\.accessibilityReduceMotion) private var reduceMotion + + var body: some View { + let moving = old != nil && !reduceMotion + TimelineView(.animation(paused: !moving)) { tick in + let t = tick.date.timeIntervalSince(changedAt) + let spring = TraceInk.ease(t, TraceInk.spring) + ChartSurface(drawing: drawing(moving && spring < 1 ? old : nil, TraceInk.ease(t, TraceInk.slide), spring)) + .equatable() + } + .onChange(of: picture) { before, _ in + old = before + changedAt = .now + } + .task(id: changedAt) { + try? await Task.sleep(for: .milliseconds(400)) + old = nil + } + } +} diff --git a/appleApp/FTWCharts/LiveTrace.swift b/appleApp/FTWCharts/LiveTrace.swift index 3edbc00..329082e 100644 --- a/appleApp/FTWCharts/LiveTrace.swift +++ b/appleApp/FTWCharts/LiveTrace.swift @@ -107,16 +107,14 @@ struct LiveTraceSurface: View { var catchUp: (fromMs: Double, at: Date)? var axisSize: CGFloat = 10 - @Environment(\.colorScheme) private var scheme @Environment(\.accessibilityReduceMotion) private var reduceMotion - @Environment(\.accessibilityReduceTransparency) private var reduceTransparency @Environment(\.scenePhase) private var phase /// Set 0.3 s after a reading came or a finger lifted, so the rest of /// the second runs at 15 fps. @State private var settledAt: Date? - static let slide: TimeInterval = 0.25 - static let spring: TimeInterval = 0.35 + static let slide = TraceInk.slide + static let spring = TraceInk.spring var body: some View { let lowPower = ProcessInfo.processInfo.isLowPowerModeEnabled @@ -137,19 +135,15 @@ struct LiveTraceSurface: View { } } - /// A critically damped ease from 0 to 1 over `duration`. static func ease(_ elapsed: TimeInterval, _ duration: TimeInterval) -> Double { - guard elapsed < duration else { return 1 } - let w = 6.0 / duration, t = max(0, elapsed) - return min(1, 1 - (1 + w * t) * exp(-w * t)) + TraceInk.ease(elapsed, duration) } private func draw(in g: inout GraphicsContext, size: CGSize, at date: Date, slides: Bool) { let series = frame.series let plot = chartFrame.plot(in: size) let px = 1 / max(g.environment.displayScale, 1) - let dark = scheme == .dark - let glows = dark && live && !reduceTransparency + let glows = TraceInk.glows(live: live, in: g) // The window's moment: held under a finger, springing back on lift. var viewMs = frozenAtMs ?? date.timeIntervalSince1970 * 1000 @@ -164,22 +158,17 @@ struct LiveTraceSurface: View { // A new scale moves as one spring, never jumps: y on the new scale // back to watts, then onto the scale on its way. var scale = CGAffineTransform.identity + var shown = series.domain if let previous = frame.previous, previous.domain != series.domain { let k = Self.ease(date.timeIntervalSince(frame.arrivedAt), Self.spring) - let h = series.size.height - let lower = previous.domain.lower + (series.domain.lower - previous.domain.lower) * k - let upper = previous.domain.upper + (series.domain.upper - previous.domain.upper) * k - let span = max(upper - lower, 1) - let aNew = -h / CGFloat(series.domain.span), bNew = h * CGFloat(series.domain.upper / series.domain.span) - let aNow = -h / CGFloat(span), bNow = h * CGFloat(upper / span) - let d = aNow / aNew - scale = CGAffineTransform(a: 1, b: 0, c: 0, d: d, tx: 0, ty: bNow - d * bNew) + shown = TraceInk.between(previous.domain, series.domain, k) + scale = TraceInk.rescale(series.domain, shown, height: series.size.height) } let baseline = CGPoint(x: 0, y: base).applying(scale).y // Axes first, still: zero, the magnitudes in the gutter, and the - // direction words when both signs show. - drawAxes(in: &g, plot: plot, px: px, baseline: plot.minY + baseline) + // direction words when both signs show, as on every chart. + ChartAxes.draw(in: &g, plot: plot, domain: series.domain, px: px, axis: axisSize, look: look, shown: shown) var plotting = g plotting.clip(to: Path(CGRect(x: plot.minX, y: plot.minY - 12, width: plot.width, height: plot.height + 24))) @@ -216,45 +205,22 @@ struct LiveTraceSurface: View { let width = series.size.width + shift + 40 let above = Path(CGRect(x: -width, y: -40, width: 3 * width, height: baseline + 40)) let below = Path(CGRect(x: -width, y: baseline, width: 3 * width, height: series.size.height - baseline + 40)) - let peak = dark ? 0.30 : 0.18 - // The band, under everything: how far the raw readings swung. - for (side, color) in [(above, look.up), (below, look.down)] { - var s = plotting - s.clip(to: side) - s.fill(p.band, with: .color(color.opacity(dark ? 0.14 : 0.10))) - } - // The body: one gradient from nothing at zero to `peak` at the - // scale's edge, in each direction's colour. - do { - var s = plotting - s.clip(to: above) - s.fill(p.body, with: .linearGradient(Gradient(colors: [look.up.opacity(0), look.up.opacity(peak)]), startPoint: CGPoint(x: 0, y: baseline), endPoint: CGPoint(x: 0, y: 0))) - s.fill(p.bodyUnseen, with: .linearGradient(Gradient(colors: [Theme.ink.opacity(0), Theme.ink.opacity(peak * 0.5)]), startPoint: CGPoint(x: 0, y: baseline), endPoint: CGPoint(x: 0, y: 0))) - } - do { - var s = plotting - s.clip(to: below) - s.fill(p.body, with: .linearGradient(Gradient(colors: [look.down.opacity(0), look.down.opacity(peak)]), startPoint: CGPoint(x: 0, y: baseline), endPoint: CGPoint(x: 0, y: series.size.height))) - s.fill(p.bodyUnseen, with: .linearGradient(Gradient(colors: [Theme.ink.opacity(0), Theme.ink.opacity(peak * 0.5)]), startPoint: CGPoint(x: 0, y: baseline), endPoint: CGPoint(x: 0, y: series.size.height))) - } + // The band, under everything: how far the raw readings swung. Then + // the body; what this phone did not see live in ink. + TraceInk.band(p.band, look: look, above: above, below: below, in: plotting) + TraceInk.body(p.body, look: look, baseline: baseline, height: series.size.height, above: above, below: below, in: plotting) + TraceInk.body(p.bodyUnseen, look: look, ink: true, baseline: baseline, height: series.size.height, above: above, below: below, in: plotting) // A silence the line does not join: a dotted baseline across it. plotting.stroke(p.gaps, with: .color(Theme.ink.opacity(0.35)), style: StrokeStyle(lineWidth: px, lineCap: .butt, lineJoin: .round, dash: [1, 3])) // What this phone did not see live: ink, no glow. - plotting.stroke(p.lineUnseen, with: .color(Theme.ink2), style: StrokeStyle(lineWidth: 2.5, lineCap: .round, lineJoin: .round)) + plotting.stroke(p.lineUnseen, with: .color(Theme.ink2), style: StrokeStyle(lineWidth: TraceInk.line, lineCap: .round, lineJoin: .round)) - // The line, each side of zero in its colour; glowing only while live. - for (side, color) in [(above, look.up), (below, look.down)] { - var s = plotting - s.clip(to: side) - if glows { - s.stroke(p.line, with: .color(color.opacity(0.12)), style: StrokeStyle(lineWidth: 7, lineCap: .round, lineJoin: .round)) - s.stroke(p.line, with: .color(color.opacity(0.25)), style: StrokeStyle(lineWidth: 4.5, lineCap: .round, lineJoin: .round)) - } - s.stroke(p.line, with: .color(live ? color : Theme.ink2), style: StrokeStyle(lineWidth: 2.5, lineCap: .round, lineJoin: .round)) - } + // The line, each side of zero in its colour; glowing only while + // live, and in ink once the reading is not. + TraceInk.line(p.line, look: live ? look : TraceLook(up: Theme.ink2, down: Theme.ink2), glows: glows, above: above, below: below, in: plotting) // The head: quiet, on the raw last reading, a small halo only while // live in the dark. @@ -267,32 +233,6 @@ struct LiveTraceSurface: View { plotting.fill(Path(ellipseIn: CGRect(x: head.x - r, y: head.y - r, width: 2 * r, height: 2 * r)), with: .color(live ? Theme.ink : Theme.ink2)) } } - - private func drawAxes(in g: inout GraphicsContext, plot: CGRect, px: CGFloat, baseline: CGFloat) { - let domain = frame.series.domain - var zero = Path() - zero.move(to: CGPoint(x: plot.minX, y: (baseline / px).rounded() * px + px / 2)) - zero.addLine(to: CGPoint(x: plot.maxX, y: (baseline / px).rounded() * px + px / 2)) - g.stroke(zero, with: .color(Theme.ink.opacity(0.35)), style: StrokeStyle(lineWidth: px, lineCap: .butt, lineJoin: .round)) - let map = YMap(domain: domain, height: plot.height) - for v in [domain.upper, domain.lower] where v != 0 { - let y = plot.minY + map.y(v) - var grid = Path() - grid.move(to: CGPoint(x: plot.minX, y: y)) - grid.addLine(to: CGPoint(x: plot.maxX, y: y)) - g.stroke(grid, with: .color(Theme.ink.opacity(0.12)), style: StrokeStyle(lineWidth: px, lineCap: .butt, lineJoin: .round, dash: [1, 3])) - let label = g.resolve(ChartText.label(PowerFormat.scale(abs(v)), size: axisSize)) - g.draw(label, at: CGPoint(x: plot.maxX + 4, y: y), anchor: .leading) - } - if domain.lower < 0, domain.upper > 0 { - if let up = look.upWord { - g.draw(g.resolve(ChartText.label(up, size: axisSize)), at: CGPoint(x: plot.minX + 2, y: plot.minY + 2), anchor: .topLeading) - } - if let down = look.downWord { - g.draw(g.resolve(ChartText.label(down, size: axisSize)), at: CGPoint(x: plot.minX + 2, y: plot.maxY - 2), anchor: .bottomLeading) - } - } - } } /// The finger on the live trace. The window holds still under it; the dot diff --git a/appleApp/FTWCharts/ScrubOverlay.swift b/appleApp/FTWCharts/ScrubOverlay.swift index e35c6ed..1e36f03 100644 --- a/appleApp/FTWCharts/ScrubOverlay.swift +++ b/appleApp/FTWCharts/ScrubOverlay.swift @@ -14,14 +14,17 @@ struct ScrubTrack: Equatable { /// "02:15 31,0 öre", per slot. var tags: [String] var landmarks: Set - /// The dot's colour. + /// The dot's colour, and below zero when that differs, as on the live + /// trace. var color: Color = Theme.ink - /// Dims what lies right of the finger, for a history's "so far". - var dimsAfter = false + var downColor: Color? /// A value that holds across its slot (a price, a plan quarter): the /// slot under the finger lights up as its whole bar and the rest dims, /// in place of a dot on a line. var bars = false + /// The last slot a finger may reach: the slots after it have not + /// happened yet. + var limit: Int? func x(_ i: Int, plot: CGRect) -> CGFloat { if let atMs, atMs.indices.contains(i) { return plot.minX + grid.x(atMs: atMs[i], width: plot.width) } @@ -51,6 +54,11 @@ struct ScrubOverlay: View { @GestureState private var touching = false @State private var size: CGSize = .zero + #if DEBUG + /// The newest track, for the debug driver: its task keeps the view + /// value it started with, often from before the data came. + @State private var newest: ScrubTrack? + #endif var body: some View { let down = model.isDown @@ -88,6 +96,7 @@ struct ScrubOverlay: View { .sensoryFeedback(.selection, trigger: model.ticks) .sensoryFeedback(.impact(weight: .light), trigger: model.bumps) #if DEBUG + .onChange(of: track, initial: true) { _, now in newest = now } .task { await autoScrub() } #endif } @@ -103,17 +112,19 @@ struct ScrubOverlay: View { } } - private func slot(_ x: CGFloat) -> Int? { + private func slot(_ x: CGFloat, on given: ScrubTrack? = nil) -> Int? { + let track = given ?? track let plot = frame.plot(in: size) - return track.grid.index(atX: x - plot.minX, width: plot.width) + guard let i = track.grid.index(atX: x - plot.minX, width: plot.width) else { return nil } + return track.limit.map { min(i, $0) } ?? i } - private func follow(_ x: CGFloat) { - guard let i = slot(x) else { + private func follow(_ x: CGFloat, on given: ScrubTrack? = nil) { + guard let i = slot(x, on: given) else { ScrubModel.log.error("touching with no slot at x \(x)") return } - model.move(to: i, landmark: track.landmarks.contains(i)) + model.move(to: i, landmark: (given ?? track).landmarks.contains(i)) } private func draw(_ i: Int, in g: inout GraphicsContext, size: CGSize) { @@ -127,15 +138,12 @@ struct ScrubOverlay: View { tag(track.tags.indices.contains(i) ? track.tags[i] : "", over: x, plot: plot, in: &g, size: size) return } - if track.dimsAfter { - g.fill(Path(CGRect(x: x, y: 0, width: max(0, plot.maxX - x), height: size.height)), with: .color(Theme.ground.opacity(0.55))) - } rule(at: x, plot: plot, px: px, in: &g) if track.values.indices.contains(i), let v = track.values[i] { let y = plot.minY + YMap(domain: track.domain, height: plot.height).y(v) let dot = CGPoint(x: x, y: y) - LockOn.dot(at: dot, color: track.color, in: &g) + LockOn.dot(at: dot, color: v < 0 ? track.downColor ?? track.color : track.color, in: &g) } tag(track.tags.indices.contains(i) ? track.tags[i] : "", over: x, plot: plot, in: &g, size: size) } @@ -212,11 +220,11 @@ struct ScrubOverlay: View { let t = (ContinuousClock.now - began) / .milliseconds(800) let phase = t.truncatingRemainder(dividingBy: 2) let along = phase < 1 ? phase : 2 - phase - follow(CGFloat(-0.05 + 1.1 * along) * size.width) + follow(CGFloat(-0.05 + 1.1 * along) * size.width, on: newest) try? await Task.sleep(for: .milliseconds(8)) } // Then two fingers, held for three seconds. - if let a = slot(0.3 * size.width), let b = slot(0.55 * size.width) { model.spread(a, b) } + if let a = slot(0.3 * size.width, on: newest), let b = slot(0.55 * size.width, on: newest) { model.spread(a, b) } try? await Task.sleep(for: .seconds(3)) model.lift() } diff --git a/appleApp/FTWCharts/TraceInk.swift b/appleApp/FTWCharts/TraceInk.swift new file mode 100644 index 0000000..6b05268 --- /dev/null +++ b/appleApp/FTWCharts/TraceInk.swift @@ -0,0 +1,86 @@ +import FTWShared +import SwiftUI + +/// The material every power trace is drawn in, live or past: the band, the +/// body, the line and its glow, and how a change of data moves. One place, +/// so a viewer who goes from Live to Today sees the time change and +/// nothing else. +enum TraceInk { + /// The line's width. + static let line: CGFloat = 2.5 + /// A second series on the same chart: thinner, under the node's own. + static let otherLine: CGFloat = 2 + /// A new value slides in over this long. + static let slide: TimeInterval = 0.25 + /// A new scale, a series coming or going, moves as one spring over this. + static let spring: TimeInterval = 0.35 + + /// A critically damped ease from 0 to 1 over `duration`. + static func ease(_ elapsed: TimeInterval, _ duration: TimeInterval) -> Double { + guard elapsed < duration else { return 1 } + let w = 6.0 / duration, t = max(0, elapsed) + return min(1, 1 - (1 + w * t) * exp(-w * t)) + } + + /// Glow means live: only while the reading is live, only in the dark, + /// and never with Reduce Transparency. + static func glows(live: Bool, in g: GraphicsContext) -> Bool { + live && g.environment.colorScheme == .dark && !g.environment.accessibilityReduceTransparency + } + + /// How far the readings swung, under everything, in each direction's + /// colour. + static func band(_ band: Path, look: TraceLook, above: Path, below: Path, in g: GraphicsContext) { + let dark = g.environment.colorScheme == .dark + for (side, color) in [(above, look.up), (below, look.down)] { + var s = g + s.clip(to: side) + s.fill(band, with: .color(color.opacity(dark ? 0.14 : 0.10))) + } + } + + /// The body: one gradient from nothing at zero to the scale's edge, in + /// each direction's colour, or in ink at half strength. + static func body(_ body: Path, look: TraceLook, ink: Bool = false, baseline: CGFloat, height: CGFloat, above: Path, below: Path, in g: GraphicsContext) { + let peak = g.environment.colorScheme == .dark ? 0.30 : 0.18 + for (side, color, edge) in [(above, look.up, CGFloat(0)), (below, look.down, height)] { + var s = g + s.clip(to: side) + let c = ink ? Theme.ink : color + let top = ink ? peak * 0.5 : peak + s.fill(body, with: .linearGradient(Gradient(colors: [c.opacity(0), c.opacity(top)]), startPoint: CGPoint(x: 0, y: baseline), endPoint: CGPoint(x: 0, y: edge))) + } + } + + /// The line, each side of zero in its colour, with the glow under it + /// when `glows`. `glowClip` limits the glow, say to the slot still + /// filling. + static func line(_ line: Path, look: TraceLook, width: CGFloat = TraceInk.line, opacity: Double = 1, glows: Bool, glowClip: Path? = nil, above: Path, below: Path, in g: GraphicsContext) { + for (side, color) in [(above, look.up), (below, look.down)] { + var s = g + s.clip(to: side) + if glows { + var glow = s + if let glowClip { glow.clip(to: glowClip) } + glow.stroke(line, with: .color(color.opacity(0.12)), style: StrokeStyle(lineWidth: 7, lineCap: .round, lineJoin: .round)) + glow.stroke(line, with: .color(color.opacity(0.25)), style: StrokeStyle(lineWidth: 4.5, lineCap: .round, lineJoin: .round)) + } + s.stroke(line, with: .color(color.opacity(opacity)), style: StrokeStyle(lineWidth: width, lineCap: .round, lineJoin: .round)) + } + } + + /// Values on scale `a` to the same values on scale `b`, in plot points. + static func rescale(_ a: YDomain, _ b: YDomain, height: CGFloat) -> CGAffineTransform { + guard a != b, a.span > 0, b.span > 0 else { return .identity } + let aa = -height / CGFloat(a.span), ab = height * CGFloat(a.upper / a.span) + let ba = -height / CGFloat(b.span), bb = height * CGFloat(b.upper / b.span) + let d = ba / aa + return CGAffineTransform(a: 1, b: 0, c: 0, d: d, tx: 0, ty: bb - d * ab) + } + + /// The scale on its way from `a` to `b`. + static func between(_ a: YDomain, _ b: YDomain, _ k: Double) -> YDomain { + guard a != b, k < 1 else { return b } + return YDomain(lower: a.lower + (b.lower - a.lower) * k, upper: a.upper + (b.upper - a.upper) * k) + } +} diff --git a/appleApp/FTWKit/Sources/FTWKit/State/ChartWords.swift b/appleApp/FTWKit/Sources/FTWKit/State/ChartWords.swift index 74ea607..34d3cc1 100644 --- a/appleApp/FTWKit/Sources/FTWKit/State/ChartWords.swift +++ b/appleApp/FTWKit/Sources/FTWKit/State/ChartWords.swift @@ -50,42 +50,6 @@ public enum Readouts { /// "14:35:12", for the live curve's seconds. public static func seconds(_ ms: Double) -> String { ZoneClock.of(.current).seconds(ms) } - /// A step of history: its power and which way, and the energy since the - /// chart's start up to the end of the step. - public static func history(_ node: EnergyNode, _ samples: [HistorySample], fromMs: Double, at ms: Double) -> ChartReadout? { - guard let i = samples.lastIndex(where: { $0.startMs <= ms }) else { return nil } - let s = samples[i] - let when = clock(max(fromMs, s.startMs)) - guard let w = s.watts else { - return ChartReadout(when: when, what: s.fetching ? NodeWords.text("fetching…") : NodeWords.text("no data")) - } - let e = EnergyToday.sum(Array(samples[...i]), fromMs: fromMs) - var what = "\(AppWords.power(abs(w))) \(word(node, w))" - if node.directions != nil { - what += " · " + NodeWords.format("↑ %@ ↓ %@ so far", AppWords.energy(e.inWh), AppWords.energy(e.outWh)) - } else { - what += " · " + NodeWords.format("%@ so far", AppWords.energy(e.inWh)) - } - return ChartReadout(when: when, what: what) - } - - /// Two fingers on history: the mean power and the energy between them. - public static func history(_ node: EnergyNode, _ samples: [HistorySample], range: ClosedRange) -> ChartReadout? { - let inside = samples.filter { $0.startMs + $0.stepMs > range.lowerBound && $0.startMs < range.upperBound } - guard !inside.isEmpty else { return nil } - let e = EnergyToday.sum(inside, fromMs: range.lowerBound) - let measured = inside.compactMap(\.watts) - guard !measured.isEmpty else { return ChartReadout(when: span(range.lowerBound, range.upperBound), what: NodeWords.text("no data")) } - let mean = measured.reduce(0, +) / Double(measured.count) - var what = NodeWords.format("average %@", AppWords.power(abs(mean))) - if node.directions != nil { - what += " · " + NodeWords.format("↑ %@ ↓ %@", AppWords.energy(e.inWh), AppWords.energy(e.outWh)) - } else { - what += " · " + AppWords.energy(e.inWh) - } - return ChartReadout(when: span(range.lowerBound, range.upperBound), what: what) - } - /// "65.5 öre/kWh" public static func price(_ minor: Double, _ currency: String) -> String { "\(AppWords.decimal(PriceStrip.text(minor, currency))) \(PriceUnits.unit(currency).perKwh)" @@ -125,11 +89,6 @@ public enum Readouts { return ChartReadout(when: span(range.lowerBound, range.upperBound), what: parts.joined(separator: " · ")) } - /// An hour of the car's charging. - public static func carHour(_ hours: [CarEnergyModel.Hour], at ms: Double) -> ChartReadout? { - guard let h = hours.first(where: { $0.startMs <= ms && ms < $0.startMs + $0.lenMs }) else { return nil } - return ChartReadout(when: span(h.startMs, h.startMs + h.lenMs), what: h.wh > 0 ? NodeWords.format("charged %@", AppWords.energy(h.wh)) : NodeWords.text("not charging")) - } } diff --git a/appleApp/FTWKit/Sources/FTWKit/State/NodeLive.swift b/appleApp/FTWKit/Sources/FTWKit/State/NodeLive.swift index f52bd57..7ba7a1a 100644 --- a/appleApp/FTWKit/Sources/FTWKit/State/NodeLive.swift +++ b/appleApp/FTWKit/Sources/FTWKit/State/NodeLive.swift @@ -122,3 +122,28 @@ public enum NodeLive { Readouts.seconds(ms) + " " + AppWords.power(abs(watts)) } } + +/// Which series a node's history shows: its own first, the others in Now's +/// order, and never none. +public enum SeriesPick { + public static func ordered(_ series: [EnergyNode], own: EnergyNode) -> [EnergyNode] { + let rest = EnergyNode.allCases.filter { $0 != own && series.contains($0) } + return (series.contains(own) ? [own] : []) + rest + } + + /// A tap on a chip: in or out, but the last one stays. + public static func toggled(_ series: [EnergyNode], _ tapped: EnergyNode, own: EnergyNode) -> [EnergyNode] { + if series.contains(tapped) { + guard series.count > 1 else { return series } + return ordered(series.filter { $0 != tapped }, own: own) + } + return ordered(series + [tapped], own: own) + } + + /// What was kept, read back: unknown names dropped, the node's own + /// series when nothing is left. + public static func read(_ stored: String?, own: EnergyNode) -> [EnergyNode] { + let kept = (stored ?? "").split(separator: ",").compactMap { EnergyNode(rawValue: String($0)) } + return ordered(kept.isEmpty ? [own] : kept, own: own) + } +} diff --git a/appleApp/FTWKit/Sources/FTWKit/State/NodeWords.swift b/appleApp/FTWKit/Sources/FTWKit/State/NodeWords.swift index 8a71bec..d4aa975 100644 --- a/appleApp/FTWKit/Sources/FTWKit/State/NodeWords.swift +++ b/appleApp/FTWKit/Sources/FTWKit/State/NodeWords.swift @@ -67,6 +67,11 @@ public enum NodeWords { "Live, the last five minutes": "Live, de senaste fem minuterna", "calculated": "beräknad", "Play as sound": "Spela som ljud", + "%@-minute means": "%@-minutersmedel", + "Close": "Stäng", + "Enlarge the chart": "Förstora diagrammet", + "FETCHING…": "HÄMTAS …", + "NOW": "NU", "Drawing as readings arrive…": "Ritas när mätningarna kommer …", "No live readings while your box is out of reach.": "Inga livevärden medan boxen inte går att nå.", "No reading yet": "Ingen mätning än", diff --git a/appleApp/FTWKit/Sources/FTWShared/Charts/AheadPicture.swift b/appleApp/FTWKit/Sources/FTWShared/Charts/AheadPicture.swift new file mode 100644 index 0000000..74350f0 --- /dev/null +++ b/appleApp/FTWKit/Sources/FTWShared/Charts/AheadPicture.swift @@ -0,0 +1,130 @@ +import CoreGraphics +import Foundation + +/// The hours ahead on one chart: what the box plans for each slot, or what +/// it forecasts, with the price under the same slots. A plan quarter is a +/// command that holds across its slot, so it is a ghost bar; a forecast is +/// an estimate, so it is a dashed line. Both start at the slot running now. +public struct AheadPicture: Equatable, Sendable { + public enum Kind: Equatable, Sendable { + case plan + case forecast + } + + public struct Tick: Equatable, Sendable { + public var ms: Double + public var label: String + } + + public var kind: Kind + public var grid: SlotGrid + public var domain: YDomain + public var size: CGSize + /// Watts per slot, signed as the node shows them; nil where the box + /// says nothing. + public var values: [Double?] + /// A forecast's dashed line through each slot's middle, one subpath per + /// run; empty for a plan. + public var line: ChartPath + /// The price per slot by third of the window's range, and its height in + /// eighths of the window's range: the block bars of the strip. + public var priceThirds: [PriceChart.Third?] + public var priceLevels: [Int?] + public var nowMs: Double + public var ticks: [Tick] + + /// `slots` and `prices` as they come; the window runs `spanMs` from the + /// slot that holds `nowMs`. + public static func build( + kind: Kind, + slots: [(startMs: Double, endMs: Double, watts: Double?)], + prices: [(startMs: Double, endMs: Double, minor: Double)], + nowMs: Double, + spanMs: Double = 12 * 3_600_000, + size: CGSize, + previous: YDomain? = nil, + floor: Double = 1, + clock: ZoneClock, + everyHours: Int = 3 + ) -> AheadPicture { + let ahead = slots.filter { $0.endMs > nowMs } + let stepMs = max(60_000, (ahead.map { $0.endMs - $0.startMs } + prices.filter { $0.startMs + ($0.endMs - $0.startMs) > nowMs }.map { $0.endMs - $0.startMs }).min() ?? 900_000) + let fromMs = ahead.first { $0.startMs <= nowMs }?.startMs ?? clock.alignDown(nowMs, stepMs: stepMs) + let count = max(1, Int((spanMs / stepMs).rounded(.up))) + let grid = SlotGrid(firstMs: fromMs, stepMs: stepMs, count: count, fromMs: fromMs, toMs: fromMs + Double(count) * stepMs) + + func value(_ items: [T], _ start: (T) -> Double, _ end: (T) -> Double, at ms: Double) -> T? { + items.first { start($0) <= ms && ms < end($0) } + } + var values = [Double?](repeating: nil, count: count) + var minor = [Double?](repeating: nil, count: count) + for i in 0.. lo else { return .middle } + return m < lo + (hi - lo) / 3 ? .cheap : m >= lo + 2 * (hi - lo) / 3 ? .dear : .middle + } + let levels: [Int?] = minor.map { m in + guard let m else { return nil } + guard hi > lo else { return 4 } + return 1 + min(7, Int(((m - lo) / (hi - lo) * 8).rounded(.down))) + } + + var ticks: [Tick] = [] + let hour = 3_600_000.0 + var t = clock.alignDown(fromMs, stepMs: hour) + hour + while t <= grid.toMs - hour { + let minute = clock.minuteOfDay(t) + if minute % 60 == 0, (minute / 60) % max(1, everyHours) == 0, t - nowMs >= 1.5 * hour { + ticks.append(Tick(ms: t, label: String(clock.label(t).prefix(2)))) + } + t += hour + } + + return AheadPicture(kind: kind, grid: grid, domain: domain, size: size, values: values, line: line, priceThirds: thirds, priceLevels: levels, nowMs: nowMs, ticks: ticks) + } + + /// A forecast's line through each slot's middle, one monotone subpath + /// per run of values. + public static func forecastLine(_ values: [Double?], grid: SlotGrid, domain: YDomain, size: CGSize) -> ChartPath { + let map = YMap(domain: domain, height: size.height) + var line = ChartPath() + var run: [CGPoint] = [] + for i in values.indices { + guard let v = values[i] else { + line.addMonotone(run) + run = [] + continue + } + run.append(CGPoint(x: grid.midX(i, width: size.width), y: map.y(v))) + } + line.addMonotone(run) + return line + } + + /// A new plan or forecast for the same slots, on its way from `old`: + /// each slot moves from its old value to its new one as `progress` + /// runs from 0 to 1, drawn on this picture's scale. A slot new to the + /// picture starts at zero; one gone is not drawn. Nil when the slots + /// differ (a new window draws at once) or the move is over. + public func blended(from old: AheadPicture, progress: Double) -> (values: [Double?], line: ChartPath)? { + guard progress < 1, old.kind == kind, old.grid == grid, old.size == size, old.values != values else { return nil } + let k = max(0, progress) + let mixed: [Double?] = zip(old.values, values).map { a, b in + guard let b else { return nil } + return (a ?? 0) + (b - (a ?? 0)) * k + } + return (mixed, kind == .forecast ? Self.forecastLine(mixed, grid: grid, domain: domain, size: size) : ChartPath()) + } +} diff --git a/appleApp/FTWKit/Sources/FTWShared/Charts/ChartSeries.swift b/appleApp/FTWKit/Sources/FTWShared/Charts/ChartSeries.swift index 542ec40..ce4a1b4 100644 --- a/appleApp/FTWKit/Sources/FTWShared/Charts/ChartSeries.swift +++ b/appleApp/FTWKit/Sources/FTWShared/Charts/ChartSeries.swift @@ -5,10 +5,13 @@ import Foundation public struct ChartSample: Equatable, Sendable { public var startMs: Double public var value: Double? + /// A hole whose tile is on its way, not one the box has no record for. + public var fetching: Bool - public init(startMs: Double, value: Double?) { + public init(startMs: Double, value: Double?, fetching: Bool = false) { self.startMs = startMs self.value = value + self.fetching = fetching } } @@ -31,6 +34,8 @@ public struct ChartBucket: Equatable, Sendable { /// Energy into and out of the site over its means, in watt-hours. public var inWh: Double public var outWh: Double + /// It holds no mean, and one of its holes is on its way. + public var fetching = false } /// The smallest bucket of 5, 15, 30 or 60 minutes that leaves each point @@ -56,9 +61,13 @@ public enum DisplayBucket { var sum = [Double](repeating: 0, count: count), at = sum, inWh = sum, outWh = sum var low = [Double](repeating: .infinity, count: count), high = [Double](repeating: -.infinity, count: count) var held = [Int](repeating: 0, count: count) + var coming = [Bool](repeating: false, count: count) let whPerW = sampleMs / 3_600_000 for s in samples where s.startMs >= fromMs && s.startMs < toMs { - guard let v = s.value else { continue } + guard let v = s.value else { + if s.fetching { coming[min(max(Int(((s.startMs - origin) / stepMs).rounded(.down)), 0), count - 1)] = true } + continue + } let k = min(max(Int(((s.startMs - origin) / stepMs).rounded(.down)), 0), count - 1) sum[k] += v held[k] += 1 @@ -81,11 +90,24 @@ public enum DisplayBucket { count: n, atMs: n > 0 ? at[k] / Double(n) : (max(start, fromMs) + min(start + stepMs, toMs)) / 2, inWh: inWh[k], - outWh: outWh[k] + outWh: outWh[k], + fetching: n == 0 && coming[k] )) } return out } + + /// The lowest and highest five-minute mean over the buckets, with zero. + public static func extent(_ buckets: [ChartBucket]) -> (low: Double, high: Double) { + var low = 0.0, high = 0.0 + for b in buckets { + if let lo = b.low, let hi = b.high { + low = min(low, lo) + high = max(high, hi) + } + } + return (low, high) + } } /// A history chart's geometry, built once per change of data off the main @@ -107,6 +129,18 @@ public struct ChartSeries: Equatable, Sendable { public var band: ChartPath /// Where each slot's value sits on the line; nil in a gap. public var points: [CGPoint?] + /// Where each slot's highest and lowest five-minute mean sit. + public var highs: [CGPoint?] + public var lows: [CGPoint?] + /// Holes on their way, and holes the box has no record for, as slots. + /// A slot after now is neither: it has not happened. + public var fetching: [ClosedRange] + public var missing: [ClosedRange] + /// A dashed straight line across each hole on its way, from the + /// measured slot before it to the one after: an estimate, never a value. + public var estimate: ChartPath + /// The last slot with data. + public var lastData: Int? public var readouts: ReadoutTable /// The history of one node between `fromMs` and `toMs` on a plot of @@ -125,6 +159,7 @@ public struct ChartSeries: Equatable, Sendable { floor: Double = 1, clock: ZoneClock, landmarksMs: [Double] = [], + domain forced: YDomain? = nil, words: ReadoutWords ) -> ChartSeries { let stepMs = DisplayBucket.stepMs(spanMs: toMs - fromMs, width: size.width) @@ -132,50 +167,47 @@ public struct ChartSeries: Equatable, Sendable { let grid = SlotGrid(firstMs: buckets.first?.startMs ?? fromMs, stepMs: stepMs, count: buckets.count, fromMs: fromMs, toMs: toMs) // The domain holds the band, not just the means, so the highest - // five-minute mean always fits. - var low = 0.0, high = 0.0 - for b in buckets { - if let lo = b.low, let hi = b.high { - low = min(low, lo) - high = max(high, hi) - } - } - let domain = YDomain.stable(low: low, high: high, previous: previous, floor: floor) + // five-minute mean always fits. Several series on one chart share + // one, given. + let extent = DisplayBucket.extent(buckets) + let domain = forced ?? YDomain.stable(low: extent.low, high: extent.high, previous: previous, floor: floor) let map = YMap(domain: domain, height: size.height) - var line = ChartPath(), body = ChartPath(), band = ChartPath() var points = [CGPoint?](repeating: nil, count: buckets.count) - var run: [CGPoint] = [], highs: [CGPoint] = [], lows: [CGPoint] = [] - run.reserveCapacity(buckets.count) - let base = map.baseline - func endRun() { - let start = line.elements.count - line.addMonotone(run) - // The body is the same curve, closed on zero: made once. - if run.count > 1 { - body.elements.append(contentsOf: line.elements[start...]) - body.elements.append(.line(CGPoint(x: run[run.count - 1].x, y: base))) - body.elements.append(.line(CGPoint(x: run[0].x, y: base))) - body.elements.append(.close) - band.addBand(highs: highs, lows: lows) - } - run.removeAll(keepingCapacity: true) - highs.removeAll(keepingCapacity: true) - lows.removeAll(keepingCapacity: true) - } + var highPoints = points, lowPoints = points for (i, b) in buckets.enumerated() { - guard let m = b.mean, let lo = b.low, let hi = b.high else { - endRun() + guard let m = b.mean, let lo = b.low, let hi = b.high else { continue } + let x = grid.x(atMs: b.atMs, width: size.width) + points[i] = CGPoint(x: x, y: map.y(m)) + highPoints[i] = CGPoint(x: x, y: map.y(hi)) + lowPoints[i] = CGPoint(x: x, y: map.y(lo)) + } + let (line, body, band) = paths(points: points, highs: highPoints, lows: lowPoints, baseline: map.baseline) + + // Holes: on their way, or none on record, but only before now. + let until = nowMs ?? toMs + var fetching: [ClosedRange] = [], missing: [ClosedRange] = [] + var estimate = ChartPath() + var i = 0 + while i < buckets.count { + guard buckets[i].mean == nil, buckets[i].startMs < until else { + i += 1 continue } - let x = grid.x(atMs: b.atMs, width: size.width) - let p = CGPoint(x: x, y: map.y(m)) - points[i] = p - run.append(p) - highs.append(CGPoint(x: x, y: map.y(hi))) - lows.append(CGPoint(x: x, y: map.y(lo))) + let coming = buckets[i].fetching + var j = i + while j + 1 < buckets.count, buckets[j + 1].mean == nil, buckets[j + 1].startMs < until, buckets[j + 1].fetching == coming { j += 1 } + if coming { + fetching.append(i...j) + if i > 0, j + 1 < buckets.count, let a = points[i - 1], let b = points[j + 1] { + estimate.elements += [.move(a), .line(b)] + } + } else { + missing.append(i...j) + } + i = j + 1 } - endRun() + let lastData = buckets.lastIndex { $0.mean != nil } var when: [String] = [], what: [String] = [] var inWh: [Double] = [0], outWh: [Double] = [0] @@ -200,9 +232,16 @@ public struct ChartSeries: Equatable, Sendable { outWh.append(outWh[i] + b.outWh) } + // As on the live trace: the high, the low, and the slots at each end + // of a run of data, where a hole begins or ends. var landmarks = Set() if let highest { landmarks.insert(highest.i) } if let lowest { landmarks.insert(lowest.i) } + for (i, b) in buckets.enumerated() where b.mean != nil { + let before = i == 0 || buckets[i - 1].mean == nil + let after = i == buckets.count - 1 || buckets[i + 1].mean == nil + if before || after { landmarks.insert(i) } + } for ms in (nowMs.map { [$0] } ?? []) + landmarksMs where ms >= fromMs && ms < toMs { if let i = grid.index(atMs: ms) { landmarks.insert(i) } } @@ -216,7 +255,66 @@ public struct ChartSeries: Equatable, Sendable { body: body, band: band, points: points, + highs: highPoints, + lows: lowPoints, + fetching: fetching, + missing: missing, + estimate: estimate, + lastData: lastData, readouts: ReadoutTable(when: when, what: what, inWh: inWh, outWh: outWh, landmarks: landmarks) ) } } + +extension ChartSeries { + /// The line, body and band through slot points: one run per stretch of + /// slots with data, so a hole stays a break. The body is the line's own + /// curve closed on zero; the band runs from the highs' curve back along + /// the lows'. + public static func paths(points: [CGPoint?], highs: [CGPoint?], lows: [CGPoint?], baseline: CGFloat) -> (line: ChartPath, body: ChartPath, band: ChartPath) { + var line = ChartPath(), body = ChartPath(), band = ChartPath() + var run: [CGPoint] = [], hi: [CGPoint] = [], lo: [CGPoint] = [] + func endRun() { + let start = line.elements.count + line.addMonotone(run) + if run.count > 1 { + body.elements.append(contentsOf: line.elements[start...]) + body.elements.append(.line(CGPoint(x: run[run.count - 1].x, y: baseline))) + body.elements.append(.line(CGPoint(x: run[0].x, y: baseline))) + body.elements.append(.close) + band.addBand(highs: hi, lows: lo) + } + run.removeAll(keepingCapacity: true) + hi.removeAll(keepingCapacity: true) + lo.removeAll(keepingCapacity: true) + } + for i in points.indices { + guard let p = points[i], let h = highs[i], let l = lows[i] else { + endRun() + continue + } + run.append(p) + hi.append(h) + lo.append(l) + } + endRun() + return (line, body, band) + } + + /// Part way from `old` to this series, `progress` from 0 to 1: each slot + /// both have moves from its old mean and swing to its new one, matched + /// by slot; a slot only this one has is drawn as it is. Nil when the two + /// do not share their slots and scale: then nothing morphs. + public func blended(from old: ChartSeries, progress: Double) -> (line: ChartPath, body: ChartPath, band: ChartPath)? { + guard progress < 1, old.grid == grid, old.domain == domain, old.points.count == points.count else { return nil } + let k = CGFloat(min(max(progress, 0), 1)) + func mix(_ a: [CGPoint?], _ b: [CGPoint?]) -> [CGPoint?] { + zip(a, b).map { o, n in + guard let n else { return nil } + guard let o else { return n } + return CGPoint(x: o.x + (n.x - o.x) * k, y: o.y + (n.y - o.y) * k) + } + } + return Self.paths(points: mix(old.points, points), highs: mix(old.highs, highs), lows: mix(old.lows, lows), baseline: YMap(domain: domain, height: size.height).baseline) + } +} diff --git a/appleApp/FTWKit/Sources/FTWShared/Charts/HistoryPicture.swift b/appleApp/FTWKit/Sources/FTWShared/Charts/HistoryPicture.swift new file mode 100644 index 0000000..dc642bb --- /dev/null +++ b/appleApp/FTWKit/Sources/FTWShared/Charts/HistoryPicture.swift @@ -0,0 +1,116 @@ +import CoreGraphics +import Foundation + +/// Today or the last two days for one or more series on one chart, built +/// once per change of data off the main actor. Every series has its own +/// line, band, body and holes; they share the slots and one stable scale +/// that holds every band, so adding a series re-fits the scale by the same +/// rules and taking one away never changes what the others say. +/// +/// Today sits on a fixed day from midnight to midnight: the line grows to +/// the right through the day and the hours ahead stay empty, so neither +/// the scale nor the slots move as the day goes on. +public struct HistoryPicture: Equatable, Sendable { + /// One series on the chart, by the key its caller gave it. + public struct Layer: Equatable, Sendable { + public var key: String + public var series: ChartSeries + } + + /// A time label: "06", or a weekday at midnight on two days. + public struct Tick: Equatable, Sendable { + public var ms: Double + public var label: String + } + + /// In the order given; the first is the chart's own series. + public var layers: [Layer] + public var grid: SlotGrid + public var domain: YDomain + public var size: CGSize + public var nowMs: Double? + /// The last slot with data in any series: a finger stops there, since + /// the slots after it have not happened. + public var limit: Int? + /// When the newest data is more than a slot old: where it ends. + public var asOfMs: Double? + public var ticks: [Tick] + + public var stepMs: Double { grid.stepMs } + + /// `inputs` in drawing order, the chart's own series first. `everyHours` + /// spaces the time labels; `weekday` names a midnight on two days. + public static func build( + _ inputs: [(key: String, samples: [ChartSample])], + fromMs: Double, + toMs: Double, + nowMs: Double?, + size: CGSize, + previous: YDomain? = nil, + floor: Double = 1, + clock: ZoneClock, + everyHours: Int = 6, + weekday: (@Sendable (Double) -> String)? = nil, + words: ReadoutWords + ) -> HistoryPicture { + // One scale for every series: the union of their bands. + let stepMs = DisplayBucket.stepMs(spanMs: toMs - fromMs, width: size.width) + var low = 0.0, high = 0.0 + for input in inputs { + let e = DisplayBucket.extent(DisplayBucket.buckets(input.samples, stepMs: stepMs, fromMs: fromMs, toMs: toMs, clock: clock)) + low = min(low, e.low) + high = max(high, e.high) + } + let domain = YDomain.stable(low: low, high: high, previous: previous, floor: floor) + let layers = inputs.map { input in + Layer(key: input.key, series: ChartSeries.history(input.samples, fromMs: fromMs, toMs: toMs, nowMs: nowMs, size: size, clock: clock, domain: domain, words: words)) + } + let grid = layers.first?.series.grid ?? SlotGrid(firstMs: fromMs, stepMs: stepMs, count: 0, fromMs: fromMs, toMs: toMs) + let limit = layers.compactMap(\.series.lastData).max() + + // The newest data, and whether it is more than a slot behind now. + var asOf: Double? + if let nowMs, let limit { + // A slot still filling ends where its five-minute means end. + let newest = layers.compactMap { layer -> Double? in + guard let i = layer.series.lastData else { return nil } + let b = layer.series.buckets[i] + return min(b.endMs, b.startMs + Double(b.count) * 300_000) + }.max() ?? grid.endMs(limit) + if nowMs - newest > stepMs { asOf = newest } + } + + // Whole local hours a few apart, a weekday at midnight when asked, + // and none within an hour of the right edge. + var ticks: [Tick] = [] + let hour = 3_600_000.0 + var t = clock.alignDown(fromMs, stepMs: hour) + if t < fromMs { t += hour } + while t <= toMs - hour { + let minute = clock.minuteOfDay(t) + if minute % 60 == 0, (minute / 60) % max(1, everyHours) == 0 { + let label = minute == 0 && weekday != nil ? weekday!(t) : String(clock.label(t).prefix(2)) + ticks.append(Tick(ms: t, label: label)) + } + t += hour + } + + return HistoryPicture(layers: layers, grid: grid, domain: domain, size: size, nowMs: nowMs, limit: limit, asOfMs: asOf, ticks: ticks) + } + + /// Local midnight before `ms` and the next one, through any turn of the + /// clocks: Today's fixed window. + public static func day(_ ms: Double, clock: ZoneClock) -> (fromMs: Double, toMs: Double) { + let date = Date(timeIntervalSince1970: ms / 1000) + let start = clock.calendar.startOfDay(for: date) + let next = clock.calendar.date(byAdding: .day, value: 1, to: start) ?? start.addingTimeInterval(86_400) + return (start.timeIntervalSince1970 * 1000, next.timeIntervalSince1970 * 1000) + } + + /// The last two days to now, on the five-minute step, so the window + /// changes once per tile and not every second. + public static func twoDays(_ nowMs: Double) -> (fromMs: Double, toMs: Double) { + let to = ((nowMs / 300_000).rounded(.up)) * 300_000 + return (to - 48 * 3_600_000, to) + } +} diff --git a/appleApp/FTWKit/Tests/FTWKitTests/NodeLiveTests.swift b/appleApp/FTWKit/Tests/FTWKitTests/NodeLiveTests.swift index 3e884d2..a66b794 100644 --- a/appleApp/FTWKit/Tests/FTWKitTests/NodeLiveTests.swift +++ b/appleApp/FTWKit/Tests/FTWKitTests/NodeLiveTests.swift @@ -76,6 +76,21 @@ import Testing #expect(sunOnly?.sun == 1) } + @Test func aChipAddsOrTakesAwayASeriesAndTheLastOneStays() { + var series = SeriesPick.read(nil, own: .battery) + #expect(series == [.battery]) + series = SeriesPick.toggled(series, .grid, own: .battery) + series = SeriesPick.toggled(series, .solar, own: .battery) + // The node's own series first, the rest in Now's order. + #expect(series == [.battery, .solar, .grid]) + series = SeriesPick.toggled(series, .battery, own: .battery) + #expect(series == [.solar, .grid]) + series = SeriesPick.toggled(series, .solar, own: .battery) + #expect(SeriesPick.toggled(series, .grid, own: .battery) == [.grid]) + #expect(SeriesPick.read("grid,bogus,home", own: .battery) == [.home, .grid]) + #expect(SeriesPick.read("", own: .solar) == [.solar]) + } + @Test func everyNewWordHasSwedish() { for key in ["LIVE", "CALCULATED", "NO READING YET", "AS OF %@", "VIA RELAY", "VIA FTW CLOUD", "READS EVERY %@ S", "LIVE · LAST 5 MIN", "5 min: made %@", "earns ≈ %@", "%@ of forecast"] { #expect(NodeWords.sv[key] != nil, "\(key)") diff --git a/appleApp/FTWKit/Tests/FTWKitTests/NodeTests.swift b/appleApp/FTWKit/Tests/FTWKitTests/NodeTests.swift index 1f23da9..e7ee66d 100644 --- a/appleApp/FTWKit/Tests/FTWKitTests/NodeTests.swift +++ b/appleApp/FTWKit/Tests/FTWKitTests/NodeTests.swift @@ -401,28 +401,6 @@ import FTWShared HistorySample(startMs: Double(i) * Self.step, stepMs: Self.step, watts: w, fetching: fetching) } - @Test func aStepSaysItsPowerItsWayAndTheEnergySoFar() { - AppWords.$language.withValue(.en) { - let samples = [sample(0, 1_200), sample(1, -600), sample(2, nil), sample(3, nil, fetching: true), sample(4, 2_400)] - let r = Readouts.history(.battery, samples, fromMs: 0, at: Self.step * 1.5) - #expect(r?.when == AppWords.clock(Self.step, zone: .current)) - #expect(r?.what == "600\u{00A0}W giving power · ↑ \(AppWords.energy(100)) ↓ \(AppWords.energy(50)) so far") - // A hole says which kind it is. - #expect(Readouts.history(.battery, samples, fromMs: 0, at: Self.step * 2.5)?.what == "no data") - #expect(Readouts.history(.battery, samples, fromMs: 0, at: Self.step * 3.5)?.what == "fetching…") - // One way only for the sun. - #expect(Readouts.history(.solar, [sample(0, 1_200)], fromMs: 0, at: 1)?.what == "1.2\u{00A0}kW making · \(AppWords.energy(100)) so far") - } - } - - @Test func twoFingersSayTheMeanAndTheEnergyBetweenThem() { - AppWords.$language.withValue(.sv) { - let samples = [sample(0, 1_200), sample(1, 600), sample(2, 0)] - let r = Readouts.history(.home, samples, range: 0...(Self.step * 2)) - #expect(r?.what == "i snitt 900\u{00A0}W · \(AppWords.energy(150))") - } - } - @Test func aSlotAheadSaysWhatThePlanDoesWhyAndThePrice() { AppWords.$language.withValue(.en) { let hour = 3_600_000.0 @@ -771,13 +749,13 @@ import FTWShared @Test func everySentenceHasSwedish() throws { let files = [ "appleApp/FTW/UI/NowView.swift", "appleApp/FTW/UI/FlowDiagram.swift", "appleApp/FTW/UI/NodeView.swift", - "appleApp/FTW/UI/NodeCharts.swift", "appleApp/FTW/UI/ModeSheet.swift", + "appleApp/FTW/UI/NodeHistory.swift", "appleApp/FTW/UI/ModeSheet.swift", "appleApp/FTW/UI/PlanCard.swift", "appleApp/FTW/UI/PlanView.swift", "appleApp/FTWKit/Sources/FTWKit/State/PlanShare.swift", "appleApp/FTWKit/Sources/FTWKit/State/EnergyNode.swift", "appleApp/FTWKit/Sources/FTWKit/State/NowFlow.swift", "appleApp/FTWKit/Sources/FTWKit/State/ModeModel.swift", "appleApp/FTWKit/Sources/FTWKit/State/PlanModel.swift", "appleApp/FTWKit/Sources/FTWKit/State/HistoryModel.swift", "appleApp/FTW/UI/PairView.swift", "appleApp/FTW/UI/RootView.swift", "appleApp/FTWKit/Sources/FTWKit/State/PairModel.swift", - "appleApp/FTW/UI/ChartScrub.swift", "appleApp/FTW/Car/CarView.swift", + "appleApp/FTW/Car/CarView.swift", "appleApp/FTWKit/Sources/FTWKit/State/ChartWords.swift", "appleApp/FTW/UI/PriceView.swift", ] let pattern = #/(?:\bL|NodeWords\.(?:text|format)|AppWords\.(?:text|format))\("((?:[^"\\]|\\.)*)"/# diff --git a/appleApp/FTWKit/Tests/FTWSharedTests/ChartGeometryTests.swift b/appleApp/FTWKit/Tests/FTWSharedTests/ChartGeometryTests.swift index 2665189..cbe5662 100644 --- a/appleApp/FTWKit/Tests/FTWSharedTests/ChartGeometryTests.swift +++ b/appleApp/FTWKit/Tests/FTWSharedTests/ChartGeometryTests.swift @@ -339,7 +339,7 @@ import Testing #expect(t.what == ["567 W · 0–1100", "1000 W", "-1500 W · -2000–-1000"]) #expect(t.soFar(0).inWh == 1_700.0 / 12) #expect(t.between(2, 1).outWh == 4_500.0 / 12) - // The highest mean, the lowest and now. + // The highest mean, the lowest, now, and the ends of the run. #expect(t.landmarks == [0, 2]) #expect(series.grid.index(atMs: from + 2_000_000) == 2) } diff --git a/appleApp/FTWKit/Tests/FTWSharedTests/HistoryRecipeTests.swift b/appleApp/FTWKit/Tests/FTWSharedTests/HistoryRecipeTests.swift new file mode 100644 index 0000000..6bac2d7 --- /dev/null +++ b/appleApp/FTWKit/Tests/FTWSharedTests/HistoryRecipeTests.swift @@ -0,0 +1,191 @@ +import CoreGraphics +import Foundation +import Testing +@testable import FTWShared + +/// History and the hours ahead on the engine (WP7 of the polish release): +/// holes, one scale for several series, Today's fixed day, the ahead +/// picture, and calm on the home box's own days. +@Suite struct HistoryRecipeTests { + static let clock = ChartGeometryTests.clock + static let words = ChartGeometryTests.words + static let size = CGSize(width: 350, height: 240) + + static func day(_ day: Int) -> (fromMs: Double, toMs: Double) { + HistoryPicture.day(ChartGeometryTests.at(day, 12), clock: clock) + } + + static func samples(from: Double, count: Int, _ value: (Int) -> Double?) -> [ChartSample] { + (0.. 80 && $0 < 200 ? 4_500 : 0 } + let both = HistoryPicture.build([("battery", battery), ("solar", solar)], fromMs: from, toMs: to, nowMs: to, size: Self.size, clock: Self.clock, words: Self.words) + let alone = HistoryPicture.build([("battery", battery)], fromMs: from, toMs: to, nowMs: to, size: Self.size, clock: Self.clock, words: Self.words) + #expect(both.domain.upper >= 4_500 && alone.domain.upper < 4_500) + #expect(both.layers.map(\.key) == ["battery", "solar"]) + #expect(both.layers.allSatisfy { $0.series.domain == both.domain }) + // The battery's readout is the same whether the sun is shown or not. + let a = try #require(both.layers.first?.series.readouts), b = try #require(alone.layers.first?.series.readouts) + #expect(a.what == b.what && a.when == b.when && a.inWh == b.inWh) + // Each series keeps its own band. + #expect(both.layers.allSatisfy { !$0.series.band.isEmpty }) + } + + @Test func aTileThatLandsMovesEachSlotFromItsOldMeanToItsNewOne() throws { + let (from, to) = Self.day(13) + let before = Self.samples(from: from, count: 120) { _ in 500 } + var after = before + for i in 108..<120 { after[i].value = 1_500 } + let a = ChartSeries.history(before, fromMs: from, toMs: to, nowMs: to, size: Self.size, clock: Self.clock, domain: YDomain(lower: 0, upper: 2_000), words: Self.words) + let b = ChartSeries.history(after, fromMs: from, toMs: to, nowMs: to, size: Self.size, clock: Self.clock, domain: YDomain(lower: 0, upper: 2_000), words: Self.words) + let start = try #require(b.blended(from: a, progress: 0)) + #expect(start.line == ChartSeries.paths(points: a.points, highs: a.highs, lows: a.lows, baseline: YMap(domain: a.domain, height: Self.size.height).baseline).line) + let half = try #require(b.blended(from: a, progress: 0.5)) + let ys = half.line.flattened().map(\.y) + let y500 = YMap(domain: b.domain, height: Self.size.height).y(500), y1500 = YMap(domain: b.domain, height: Self.size.height).y(1_500) + #expect((ys.min() ?? 0) > y1500 - 1e-6 && (ys.max() ?? 0) < y500 + 1e-6) + #expect(b.blended(from: a, progress: 1) == nil) + // Another window or scale morphs nothing: it cross-fades. + let other = ChartSeries.history(after, fromMs: from, toMs: to, nowMs: to, size: Self.size, clock: Self.clock, domain: YDomain(lower: 0, upper: 3_000), words: Self.words) + #expect(other.blended(from: a, progress: 0.5) == nil) + } + + @Test func todayIsAFixedDayFromMidnightToMidnight() { + let (from, to) = Self.day(13) + #expect(to - from == 86_400_000) + // The day the clocks go back is 25 hours long. + let (a, b) = HistoryPicture.day(ChartGeometryTests.clock.calendar.date(from: DateComponents(year: 2026, month: 10, day: 25, hour: 12))!.timeIntervalSince1970 * 1000, clock: Self.clock) + #expect(b - a == 25 * 3_600_000) + let now = from + 15 * 3_600_000 + 20 * 60_000 + let picture = HistoryPicture.build([("home", Self.samples(from: from, count: 184) { _ in 600 })], fromMs: from, toMs: to, nowMs: now, size: Self.size, clock: Self.clock, words: Self.words) + #expect(picture.ticks.map(\.label) == ["00", "06", "12", "18"]) + #expect(picture.limit == 30) + #expect(picture.asOfMs == nil) + // An hour without tiles before now says where the data ends. + let stale = HistoryPicture.build([("home", Self.samples(from: from, count: 160) { _ in 600 })], fromMs: from, toMs: to, nowMs: now, size: Self.size, clock: Self.clock, words: Self.words) + #expect(stale.asOfMs == from + 160 * 300_000) + let window = HistoryPicture.twoDays(now) + #expect(window.toMs - window.fromMs == 48 * 3_600_000 && window.toMs.truncatingRemainder(dividingBy: 300_000) == 0) + } + + @Test func twoDaysNameEachMidnight() { + let now = ChartGeometryTests.at(14, 10, 3) + let (from, to) = HistoryPicture.twoDays(now) + let picture = HistoryPicture.build([("grid", Self.samples(from: from, count: 576) { _ in 100 })], fromMs: from, toMs: to, nowMs: now, size: Self.size, clock: Self.clock, everyHours: 12, weekday: { _ in "MON" }, words: Self.words) + #expect(picture.grid.stepMs == 3_600_000) + #expect(picture.ticks.map(\.label) == ["12", "MON", "12", "MON"]) + } + + // MARK: Ahead + + @Test func thePlanAheadIsGhostSlotsAndAForecastIsAnEstimatedLine() { + let now = ChartGeometryTests.at(14, 10, 7) + let start = ChartGeometryTests.at(14, 10) + func watts(_ i: Int) -> Double { i < 8 ? 3_000 : i < 20 ? -2_000 : 0 } + var slots: [(startMs: Double, endMs: Double, watts: Double?)] = [] + var prices: [(startMs: Double, endMs: Double, minor: Double)] = [] + for i in 0..<48 { + let a = start + Double(i) * 900_000 + slots.append((a, a + 900_000, watts(i))) + prices.append((a, a + 900_000, 40 + Double(i))) + } + let plan = AheadPicture.build(kind: .plan, slots: slots, prices: prices, nowMs: now, size: Self.size, floor: 3_000, clock: Self.clock) + #expect(plan.grid.firstMs == start && plan.grid.count == 48) + #expect(plan.values[0] == 3_000 && plan.values[10] == -2_000) + #expect(plan.domain.lower <= -2_000 && plan.domain.upper >= 3_000) + #expect(plan.line.isEmpty) + #expect(plan.priceThirds[0] == PriceChart.Third.cheap && plan.priceThirds[47] == PriceChart.Third.dear) + #expect(plan.priceLevels[0] == 1 && plan.priceLevels[47] == 8) + #expect(plan.ticks.map(\.label) == ["12", "15", "18", "21"]) + let made: [(startMs: Double, endMs: Double, watts: Double?)] = slots.map { ($0.startMs, $0.endMs, abs($0.watts ?? 0)) } + let forecast = AheadPicture.build(kind: .forecast, slots: made, prices: [], nowMs: now, size: Self.size, clock: Self.clock) + #expect(!forecast.line.isEmpty) + #expect(forecast.priceThirds.allSatisfy { (t: PriceChart.Third?) in t == nil }) + } + + @Test func aNewPlanForTheSameSlotsMovesEachSlotToItsNewValue() throws { + let now = ChartGeometryTests.at(14, 10, 7) + let start = ChartGeometryTests.at(14, 10) + func slots(_ w: @escaping (Int) -> Double?) -> [(startMs: Double, endMs: Double, watts: Double?)] { + (0..<48).map { i in (start + Double(i) * 900_000, start + Double(i + 1) * 900_000, w(i)) } + } + let old = AheadPicture.build(kind: .forecast, slots: slots { _ in 1_000 }, prices: [], nowMs: now, size: Self.size, floor: 3_000, clock: Self.clock) + let new = AheadPicture.build(kind: .forecast, slots: slots { $0 == 5 ? nil : 2_000 }, prices: [], nowMs: now, size: Self.size, previous: old.domain, floor: 3_000, clock: Self.clock) + let half = try #require(new.blended(from: old, progress: 0.5)) + #expect(half.values[0] == 1_500 && half.values[5] == nil) + #expect(!half.line.isEmpty) + #expect(new.blended(from: old, progress: 1) == nil) + // A window that moved on draws at once. + let later = AheadPicture.build(kind: .forecast, slots: slots { _ in 2_000 }, prices: [], nowMs: now + 900_000, size: Self.size, floor: 3_000, clock: Self.clock) + #expect(later.blended(from: old, progress: 0.5) == nil) + } + + // MARK: Calm on the home box's days + + struct Days: Decodable { + var t0_ms: Double + var series: [String: [[Double]]] + } + + static func recorded() throws -> Days { + let url = try #require(Bundle.module.url(forResource: "sungrow-days", withExtension: "json", subdirectory: "Resources")) + return try JSONDecoder().decode(Days.self, from: Data(contentsOf: url)) + } + + /// Today on the home box, 12 and 13 October 2026, drawn as WP7 does + /// (30-minute means) and as 0.3.2 did (every five-minute mean through a + /// monotone curve). + @Test func todayIsCalmOnTheHomeBoxsOwnDays() throws { + let days = try Self.recorded() + for name in ["battery_w", "pv_w", "meter_w"] { + let rows = try #require(days.series[name]) + let samples = rows.map { ChartSample(startMs: days.t0_ms + $0[0] * 1_000, value: $0[1]) } + for d in [12, 13] { + let (from, to) = Self.day(d) + let series = ChartSeries.history(samples, fromMs: from, toMs: to, nowMs: to, size: CGSize(width: 350, height: 280), floor: 3_000, clock: Self.clock, words: Self.words) + let turns = Calm.turnsPer100(series.line.flattened(steps: 8)) + let map = YMap(domain: series.domain, height: 280) + let fine = samples.filter { $0.startMs >= from && $0.startMs < to }.compactMap { s in s.value.map { CGPoint(x: series.grid.x(atMs: s.startMs + 150_000, width: 350), y: map.y($0)) } } + let before = Calm.turnsPer100(ChartPaths.monotone(fine).flattened(steps: 8)) + #expect(turns <= 12, "\(name) on \(d) Oct: \(turns) turns per 100 pt") + #expect(turns < before / 2 || before < 4, "\(name) on \(d) Oct: \(turns) against \(before) in 0.3.2") + } + } + } +} diff --git a/appleApp/FTWKit/Tests/FTWSharedTests/Resources/sungrow-days.json b/appleApp/FTWKit/Tests/FTWSharedTests/Resources/sungrow-days.json new file mode 100644 index 0000000..e3775e4 --- /dev/null +++ b/appleApp/FTWKit/Tests/FTWSharedTests/Resources/sungrow-days.json @@ -0,0 +1 @@ +{"source":"home box, sungrow driver, /api/series, 5-minute means","step_ms":300000,"series":{"battery_w":[[-300,194],[0,1274],[300,2223],[600,4802],[900,1512],[1200,-259],[1500,388],[1800,457],[2100,-296],[2400,1307],[2700,399],[3000,335],[3300,422],[3600,1316],[3900,1628],[4200,443],[4500,758],[4800,3166],[5100,1210],[5400,1810],[5700,2357],[6000,2496],[6300,855],[6600,1137],[6900,-1618],[7200,-653],[7500,230],[7800,372],[8100,2335],[8400,1481],[8700,495],[9000,2],[9300,-294],[9600,-267],[9900,59],[10200,-44],[10500,361],[10800,4027],[11100,4887],[11400,4754],[11700,4806],[12000,3775],[12300,4078],[12600,3633],[12900,2239],[13200,18],[13500,-325],[13800,3615],[14100,4768],[14400,4130],[14700,1629],[15000,1059],[15300,1023],[15600,1065],[15900,2628],[16200,2256],[16500,0],[16800,562],[17100,0],[17400,18],[17700,14],[18000,41],[18300,0],[18600,0],[18900,19],[19200,0],[19500,5],[19800,3],[20100,0],[20400,24],[20700,-656],[21000,-1323],[21300,-2581],[21600,2094],[21900,755],[22200,676],[22500,1149],[22800,86],[23100,9],[23400,168],[23700,0],[24000,0],[24300,0],[24600,0],[24900,0],[25200,0],[25500,-1527],[25800,-2134],[26100,-3229],[26400,-3102],[26700,-3450],[27000,-3455],[27300,-2577],[27600,-1494],[27900,-1557],[28200,-3642],[28500,-2797],[28800,-1755],[29100,-1936],[29400,-1520],[29700,-1415],[30000,-1460],[30300,-1428],[30600,-1401],[30900,-1349],[31200,-1807],[31500,-3354],[31800,-3354],[32100,-1639],[32400,-1401],[32700,-1519],[33000,-1492],[33300,-1452],[33600,-1485],[33900,-1143],[34200,-686],[34500,-640],[34800,-520],[35100,-509],[35400,-509],[35700,-509],[36000,-509],[36300,-503],[36600,-454],[36900,-464],[37200,-480],[37500,-524],[37800,-506],[38100,-517],[38400,-494],[38700,-435],[39000,-396],[39300,-339],[39600,-335],[39900,-335],[40200,-421],[40500,-468],[40800,-506],[41100,-462],[41400,-375],[41700,-375],[42000,-376],[42300,-376],[42600,-376],[42900,-414],[43200,-458],[43500,-373],[43800,-302],[44100,-230],[44400,-232],[44700,-233],[45000,-231],[45300,-30],[45600,0],[45900,0],[46200,-356],[46500,-397],[46800,-361],[47100,-297],[47400,-337],[47700,-366],[48000,-45],[48300,0],[48600,0],[48900,0],[49200,0],[49500,0],[49800,0],[50100,0],[50400,0],[50700,0],[51000,0],[51300,0],[51600,3131],[51900,3419],[52200,3622],[52500,1583],[52800,1023],[53100,1612],[53400,442],[53700,0],[54000,0],[54300,0],[54600,0],[54900,0],[55200,0],[55500,0],[55800,-15],[56100,294],[56400,0],[56700,1513],[57000,379],[57300,0],[57600,0],[57900,0],[58200,0],[58500,0],[58800,3188],[59100,3622],[59400,3626],[59700,662],[60000,459],[60300,2748],[60600,456],[60900,0],[61200,0],[61500,585],[61800,647],[62100,383],[62400,3173],[62700,3806],[63000,3580],[63300,1746],[63600,435],[63900,840],[64200,1482],[64500,1188],[64800,1262],[65100,3599],[65400,3561],[65700,3545],[66000,1392],[66300,140],[66600,292],[66900,3623],[67200,3831],[67500,3785],[67800,503],[68100,152],[68400,336],[68700,304],[69000,222],[69300,155],[69600,2140],[69900,3095],[70200,2699],[70500,0],[70800,0],[71100,0],[71400,0],[71700,0],[72000,0],[72300,-1125],[72600,-1312],[72900,-1386],[73200,-1255],[73500,-1316],[73800,-1222],[74100,-1232],[74400,-1397],[74700,-1488],[75000,-1349],[75300,-1341],[75600,-1373],[75900,-1388],[76200,-937],[76500,-738],[76800,-766],[77100,-804],[77400,-587],[77700,-619],[78000,-505],[78300,-473],[78600,-496],[78900,-507],[79200,-554],[79500,-546],[79800,-151],[80100,0],[80400,0],[80700,-104],[81000,122],[81300,96],[81600,0],[81900,0],[82200,0],[82500,0],[82800,0],[83100,0],[83400,0],[83700,0],[84000,0],[84300,0],[84600,-491],[84900,-1259],[85200,-1508],[85500,-1451],[85800,-1222],[86100,-1111],[86400,-1126],[86700,-1453],[87000,-1650],[87300,-1764],[87600,-170],[87900,1611],[88200,3216],[88500,510],[88800,251],[89100,1467],[89400,2127],[89700,801],[90000,9],[90300,0],[90600,249],[90900,1504],[91200,927],[91500,951],[91800,212],[92100,2723],[92400,652],[92700,488],[93000,411],[93300,827],[93600,44],[93900,366],[94200,790],[94500,172],[94800,0],[95100,606],[95400,1251],[95700,1011],[96000,831],[96300,1336],[96600,2035],[96900,1440],[97200,899],[97500,549],[97800,650],[98100,787],[98400,780],[98700,998],[99000,1005],[99300,613],[99600,209],[99900,269],[100200,2062],[100500,1890],[100800,735],[101100,9],[101400,0],[101700,15],[102000,0],[102300,0],[102600,0],[102900,0],[103200,0],[103500,0],[103800,0],[104100,0],[104400,0],[104700,0],[105000,0],[105300,0],[105600,0],[105900,0],[106200,0],[106500,0],[106800,0],[107100,0],[107400,0],[107700,0],[108000,0],[108300,0],[108600,0],[108900,0],[109200,-952],[109500,0],[109800,0],[110100,-962],[110400,-2760],[110700,-2192],[111000,-181],[111300,0],[111600,0],[111900,-433],[112200,-514],[112500,-639],[112800,-832],[113100,-862],[113400,-981],[113700,-1590],[114000,-1489],[114300,-1479],[114600,-1512],[114900,-1550],[115200,-1571],[115500,-2107],[115800,-3684],[116100,-1620],[116400,-1196],[116700,-3224],[117000,-2548],[117300,-830],[117600,-792],[117900,-951],[118200,-2754],[118500,-2135],[118800,-801],[119100,-727],[119400,-715],[119700,-704],[120000,-617],[120300,-509],[120600,-632],[120900,-700],[121200,-1088],[121500,-2545],[121800,-2481],[122100,-943],[122400,-429],[122700,-512],[123000,-631],[123300,-631],[123600,-504],[123900,-455],[124200,-477],[124500,-476],[124800,-481],[125100,-552],[125400,-629],[125700,-562],[126000,-413],[126300,-469],[126600,-469],[126900,-448],[127200,-495],[127500,-527],[127800,-615],[128100,-69],[128400,0],[128700,0],[129000,-419],[129300,-511],[129600,-498],[129900,-65],[130200,0],[130500,0],[130800,0],[131100,0],[131400,0],[131700,-16],[132000,0],[132300,0],[132600,0],[132900,0],[133200,0],[133500,0],[133800,0],[134100,0],[134400,0],[134700,0],[135000,0],[135300,0],[135600,0],[135900,0],[136200,0],[136500,0],[136800,0],[137100,0],[137400,0],[137700,0],[138000,0],[138300,0],[138600,0],[138900,0],[139200,-22],[139500,0],[139800,0],[140100,0],[140400,0],[140700,0],[141000,0],[141300,0],[141600,0],[141900,0],[142200,0],[142500,0],[142800,0],[143100,0],[143400,0],[143700,0],[144000,0],[144300,0],[144600,0],[144900,0],[145200,0],[145500,0],[145800,0],[146100,0],[146400,0],[146700,0],[147000,0],[147300,0],[147600,0],[147900,0],[148200,0],[148500,0],[148800,0],[149100,0],[149400,0],[149700,0],[150000,0],[150300,0],[150600,0],[150900,0],[151200,0],[151500,0],[151800,0],[152100,0],[152400,0],[152700,0],[153000,0],[153300,0],[153600,0],[153900,0],[154200,0],[154500,0],[154800,0],[155100,0],[155400,0],[155700,0],[156000,0],[156300,0],[156600,0],[156900,194],[157200,612],[157500,1582],[157800,161],[158100,0],[158400,0],[158700,0],[159000,0],[159300,0],[159600,-967],[159900,-804],[160200,0],[160500,0],[160800,0],[161100,0],[161400,-960],[161700,-896],[162000,0],[162300,-1051],[162600,-895],[162900,0],[163200,-1060],[163500,-875],[163800,0],[164100,0],[164400,0],[164700,0],[165000,0],[165300,-296],[165600,-1267],[165900,-148],[166200,0],[166500,73],[166800,0],[167100,0],[167400,0],[167700,0],[168000,0],[168300,0],[168600,0],[168900,0],[169200,0],[169500,0],[169800,0],[170100,0],[170400,0],[170700,0],[171000,0],[171300,0],[171600,0],[171900,0],[172200,0],[172500,0],[172800,0],[173100,0],[173400,0],[173700,0],[174000,122],[174300,0],[174600,0],[174900,-1032],[175200,-327],[175500,574],[175800,623],[176100,721],[176400,905],[176700,1053],[177000,828],[177300,644],[177600,455],[177900,191],[178200,195],[178500,195],[178800,341],[179100,458],[179400,796],[179700,679],[180000,657],[180300,699],[180600,1014],[180900,936],[181200,1036],[181500,952],[181800,1038],[182100,879],[182400,811],[182700,765],[183000,866],[183300,673],[183600,694],[183900,688],[184200,830],[184500,521],[184800,631],[185100,607],[185400,595],[185700,582],[186000,396],[186300,576],[186600,2684],[186900,3659],[187200,3662],[187500,1092],[187800,1051],[188100,959],[188400,789],[188700,868],[189000,868],[189300,889],[189600,905],[189900,866],[190200,703],[190500,451],[190800,361],[191100,242],[191400,101],[191700,17],[192000,43],[192300,0],[192600,0],[192900,0],[193200,0],[193500,-188],[193800,-28],[194100,0],[194400,0],[194700,0],[195000,0],[195300,0],[195600,-673],[195900,-1767],[196200,-2200],[196500,-994],[196800,0],[197100,0],[197400,-2018],[197700,-2363],[198000,-2400],[198300,-288],[198600,0],[198900,0],[199200,0],[199500,0],[199800,0],[200100,0],[200400,0],[200700,0],[201000,-2047],[201300,-2143],[201600,-1892],[201900,-1532],[202200,-1475],[202500,-1606],[202800,-1624],[203100,-1604],[203400,-1626],[203700,-1476],[204000,-3080],[204300,-2367],[204600,-1607],[204900,-2270],[205200,-2961],[205500,-348],[205800,0],[206100,0],[206400,0],[206700,0],[207000,0],[207300,0],[207600,0],[207900,0],[208200,0],[208500,0],[208800,0],[209100,0],[209400,0],[209700,0],[210000,0],[210300,0],[210600,0],[210900,0],[211200,0],[211500,0],[211800,0],[212100,0],[212400,0],[212700,0],[213000,0],[213300,0],[213600,0],[213900,0],[214200,0],[214500,0],[214800,0],[215100,0],[215400,0],[215700,0],[216000,0],[216300,0],[216600,0],[216900,0],[217200,0],[217500,0],[217800,0],[218100,0],[218400,0],[218700,0],[219000,0],[219300,0],[219600,0],[219900,0],[220200,0],[220500,0],[220800,0],[221100,0],[221400,0],[221700,0],[222000,0],[222300,0],[222600,3170],[222900,3593],[223200,3553],[223500,3124],[223800,3234],[224100,3234],[224400,3510],[224700,3717],[225000,3724],[225300,3868],[225600,3859],[225900,3831],[226200,3823],[226500,3733],[226800,3634],[227100,3753],[227400,3870],[227700,3874],[228000,3841],[228300,3327],[228600,3376],[228900,3583],[229200,3529],[229500,3525],[229800,59],[230100,0],[230400,0],[230700,0],[231000,0],[231300,0],[231600,0],[231900,0],[232200,0],[232500,0],[232800,0],[233100,0],[233400,0],[233700,0],[234000,0],[234300,20],[234600,0],[234900,0],[235200,0],[235500,0],[235800,0],[236100,0],[236400,0],[236700,0],[237000,10],[237300,0],[237600,0],[237900,0],[238200,0],[238500,0],[238800,0],[239100,0],[239400,0],[239700,0],[240000,0],[240300,0],[240600,17],[240900,0],[241200,0],[241500,0],[241800,0],[242100,0],[242400,0],[242700,0],[243000,0],[243300,0],[243600,0],[243900,0],[244200,18],[244500,0],[244800,0],[245100,4],[245400,0],[245700,0],[246000,-1129],[246300,-1340],[246600,-1348],[246900,-1294],[247200,-1288],[247500,-1324],[247800,-1332],[248100,-1317],[248400,-1242],[248700,-4090],[249000,-4447],[249300,-4426],[249600,-4403],[249900,-4381],[250200,-4363],[250500,-4349],[250800,-4335],[251100,-4321],[251400,-4304],[251700,-4283],[252000,-4257],[252300,-4226],[252600,-4190],[252900,-4149],[253200,-2416],[253500,-2013],[253800,-2013],[254100,-389],[254400,110],[254700,818],[255000,853],[255300,478],[255600,730],[255900,-946],[256200,-1518],[256500,-1518],[256800,1973],[257100,2486],[257400,1683],[257700,2160],[258000,2210],[258300,3273],[258600,-1512]],"pv_w":[[-300,-1842],[0,-2889],[300,-4217],[600,-6927],[900,-3110],[1200,-1303],[1500,-2021],[1800,-2103],[2100,-1310],[2400,-2454],[2700,-1368],[3000,-1338],[3300,-1242],[3600,-2367],[3900,-3055],[4200,-1355],[4500,-1670],[4800,-5102],[5100,-1868],[5400,-2521],[5700,-3218],[6000,-3551],[6300,-1892],[6600,-1857],[6900,-1370],[7200,-1370],[7500,-1134],[7800,-1740],[8100,-3605],[8400,-2842],[8700,-1679],[9000,-1004],[9300,-859],[9600,-1000],[9900,-1273],[10200,-1459],[10500,-2333],[10800,-6772],[11100,-7576],[11400,-6833],[11700,-6932],[12000,-5719],[12300,-5947],[12600,-4820],[12900,-3038],[13200,-824],[13500,-499],[13800,-641],[14100,-1320],[14400,-1377],[14700,-1062],[15000,-814],[15300,-951],[15600,-2578],[15900,-4799],[16200,-6082],[16500,-3981],[16800,-2921],[17100,-4498],[17400,-4684],[17700,-3877],[18000,-4684],[18300,-2272],[18600,-600],[18900,-661],[19200,-997],[19500,-1662],[19800,-1405],[20100,-1177],[20400,-857],[20700,-641],[21000,-521],[21300,-387],[21600,-285],[21900,-282],[22200,-297],[22500,-375],[22800,-901],[23100,-1215],[23400,-1236],[23700,-1380],[24000,-1174],[24300,-789],[24600,-511],[24900,-373],[25200,-284],[25500,-225],[25800,-186],[26100,-164],[26400,-130],[26700,-98],[27000,-66],[27300,-35],[27600,0],[27900,0],[28200,0],[28500,0],[28800,0],[29100,0],[29400,0],[29700,0],[30000,0],[30300,0],[30600,0],[30900,0],[31200,0],[31500,0],[31800,0],[32100,0],[32400,0],[32700,0],[33000,0],[33300,0],[33600,0],[33900,0],[34200,0],[34500,0],[34800,0],[35100,0],[35400,0],[35700,0],[36000,0],[36300,0],[36600,0],[36900,0],[37200,0],[37500,0],[37800,0],[38100,0],[38400,0],[38700,0],[39000,0],[39300,0],[39600,0],[39900,0],[40200,0],[40500,0],[40800,0],[41100,0],[41400,0],[41700,0],[42000,0],[42300,0],[42600,0],[42900,0],[43200,0],[43500,0],[43800,0],[44100,0],[44400,0],[44700,0],[45000,0],[45300,0],[45600,0],[45900,0],[46200,0],[46500,0],[46800,0],[47100,0],[47400,0],[47700,0],[48000,0],[48300,0],[48600,0],[48900,0],[49200,0],[49500,0],[49800,0],[50100,0],[50400,0],[50700,0],[51000,0],[51300,0],[51600,0],[51900,0],[52200,0],[52500,0],[52800,0],[53100,0],[53400,0],[53700,0],[54000,0],[54300,0],[54600,0],[54900,0],[55200,0],[55500,0],[55800,0],[56100,0],[56400,0],[56700,0],[57000,0],[57300,0],[57600,0],[57900,0],[58200,0],[58500,0],[58800,0],[59100,0],[59400,0],[59700,0],[60000,0],[60300,0],[60600,0],[60900,0],[61200,0],[61500,0],[61800,0],[62100,0],[62400,0],[62700,0],[63000,0],[63300,0],[63600,0],[63900,0],[64200,0],[64500,0],[64800,0],[65100,0],[65400,0],[65700,0],[66000,0],[66300,0],[66600,0],[66900,0],[67200,0],[67500,0],[67800,0],[68100,0],[68400,0],[68700,0],[69000,0],[69300,0],[69600,0],[69900,0],[70200,0],[70500,0],[70800,0],[71100,0],[71400,0],[71700,0],[72000,0],[72300,0],[72600,0],[72900,0],[73200,0],[73500,0],[73800,0],[74100,0],[74400,0],[74700,0],[75000,0],[75300,0],[75600,0],[75900,0],[76200,0],[76500,0],[76800,0],[77100,0],[77400,0],[77700,-59],[78000,-228],[78300,-239],[78600,-221],[78900,-250],[79200,-337],[79500,-312],[79800,-465],[80100,-644],[80400,-835],[80700,-574],[81000,-262],[81300,-3007],[81600,-4164],[81900,-4083],[82200,-1972],[82500,-1490],[82800,-1115],[83100,-1162],[83400,-1345],[83700,-1715],[84000,-2040],[84300,-1772],[84600,-1449],[84900,-1321],[85200,-1002],[85500,-1086],[85800,-1159],[86100,-1336],[86400,-1318],[86700,-1089],[87000,-1007],[87300,-1119],[87600,-1381],[87900,-3947],[88200,-6122],[88500,-2853],[88800,-2523],[89100,-2164],[89400,-3671],[89700,-3210],[90000,-1824],[90300,-1238],[90600,-1043],[90900,-2163],[91200,-1467],[91500,-1642],[91800,-1701],[92100,-3429],[92400,-1205],[92700,-1223],[93000,-1754],[93300,-2021],[93600,-1274],[93900,-1679],[94200,-1942],[94500,-1413],[94800,-1278],[95100,-1679],[95400,-1960],[95700,-1689],[96000,-1526],[96300,-2058],[96600,-2691],[96900,-2045],[97200,-1407],[97500,-1197],[97800,-1273],[98100,-1391],[98400,-1535],[98700,-1721],[99000,-1631],[99300,-1186],[99600,-1112],[99900,-1118],[100200,-1355],[100500,-1338],[100800,-1271],[101100,-1290],[101400,-1136],[101700,-917],[102000,-862],[102300,-838],[102600,-1062],[102900,-1174],[103200,-1102],[103500,-966],[103800,-1227],[104100,-929],[104400,-1086],[104700,-893],[105000,-555],[105300,-384],[105600,-446],[105900,-819],[106200,-694],[106500,-597],[106800,-642],[107100,-421],[107400,-294],[107700,-435],[108000,-698],[108300,-641],[108600,-542],[108900,-481],[109200,-434],[109500,-411],[109800,-354],[110100,-297],[110400,-275],[110700,-213],[111000,-159],[111300,-114],[111600,-80],[111900,-70],[112200,-45],[112500,-33],[112800,0],[113100,0],[113400,0],[113700,0],[114000,0],[114300,0],[114600,0],[114900,0],[115200,0],[115500,0],[115800,0],[116100,0],[116400,0],[116700,0],[117000,0],[117300,0],[117600,0],[117900,0],[118200,0],[118500,0],[118800,0],[119100,0],[119400,0],[119700,0],[120000,0],[120300,0],[120600,0],[120900,0],[121200,0],[121500,0],[121800,0],[122100,0],[122400,0],[122700,0],[123000,0],[123300,0],[123600,0],[123900,0],[124200,0],[124500,0],[124800,0],[125100,0],[125400,0],[125700,0],[126000,0],[126300,0],[126600,0],[126900,0],[127200,0],[127500,0],[127800,0],[128100,0],[128400,0],[128700,0],[129000,0],[129300,0],[129600,0],[129900,0],[130200,0],[130500,0],[130800,0],[131100,0],[131400,0],[131700,0],[132000,0],[132300,0],[132600,0],[132900,0],[133200,0],[133500,0],[133800,0],[134100,0],[134400,0],[134700,0],[135000,0],[135300,0],[135600,0],[135900,0],[136200,0],[136500,0],[136800,0],[137100,0],[137400,0],[137700,0],[138000,0],[138300,0],[138600,0],[138900,0],[139200,0],[139500,0],[139800,0],[140100,0],[140400,0],[140700,0],[141000,0],[141300,0],[141600,0],[141900,0],[142200,0],[142500,0],[142800,0],[143100,0],[143400,0],[143700,0],[144000,0],[144300,0],[144600,0],[144900,0],[145200,0],[145500,0],[145800,0],[146100,0],[146400,0],[146700,0],[147000,0],[147300,0],[147600,0],[147900,0],[148200,0],[148500,0],[148800,0],[149100,0],[149400,0],[149700,0],[150000,0],[150300,0],[150600,0],[150900,0],[151200,0],[151500,0],[151800,0],[152100,0],[152400,0],[152700,0],[153000,0],[153300,0],[153600,0],[153900,0],[154200,0],[154500,0],[154800,0],[155100,0],[155400,0],[155700,0],[156000,0],[156300,0],[156600,0],[156900,0],[157200,0],[157500,0],[157800,0],[158100,0],[158400,0],[158700,0],[159000,0],[159300,0],[159600,0],[159900,0],[160200,0],[160500,0],[160800,0],[161100,0],[161400,0],[161700,0],[162000,0],[162300,0],[162600,0],[162900,0],[163200,-33],[163500,-166],[163800,-346],[164100,-618],[164400,-859],[164700,-1397],[165000,-1776],[165300,-2024],[165600,-2247],[165900,-2471],[166200,-2749],[166500,-2975],[166800,-3244],[167100,-3442],[167400,-3629],[167700,-3815],[168000,-4002],[168300,-4167],[168600,-4330],[168900,-4491],[169200,-4651],[169500,-4805],[169800,-4950],[170100,-5106],[170400,-5254],[170700,-5411],[171000,-5465],[171300,-5015],[171600,-4196],[171900,-2489],[172200,-2139],[172500,-2276],[172800,-2440],[173100,-2695],[173400,-2807],[173700,-3034],[174000,-2046],[174300,-1856],[174600,-1767],[174900,-1516],[175200,-1171],[175500,-1082],[175800,-1132],[176100,-1232],[176400,-1400],[176700,-1453],[177000,-1261],[177300,-1093],[177600,-1049],[177900,-1055],[178200,-1019],[178500,-996],[178800,-1125],[179100,-1218],[179400,-1465],[179700,-1419],[180000,-1418],[180300,-1456],[180600,-1547],[180900,-1523],[181200,-1585],[181500,-1450],[181800,-1420],[182100,-1338],[182400,-1286],[182700,-1243],[183000,-1265],[183300,-1260],[183600,-1279],[183900,-1239],[184200,-1227],[184500,-1312],[184800,-1397],[185100,-1352],[185400,-1251],[185700,-1364],[186000,-1268],[186300,-1397],[186600,-1427],[186900,-1373],[187200,-1503],[187500,-1538],[187800,-1609],[188100,-1407],[188400,-1471],[188700,-1579],[189000,-1731],[189300,-1628],[189600,-1441],[189900,-1304],[190200,-1157],[190500,-928],[190800,-768],[191100,-773],[191400,-786],[191700,-631],[192000,-594],[192300,-580],[192600,-635],[192900,-541],[193200,-441],[193500,-425],[193800,-432],[194100,-452],[194400,-369],[194700,-106],[195000,-87],[195300,-80],[195600,-186],[195900,-341],[196200,-351],[196500,-358],[196800,-318],[197100,-283],[197400,-227],[197700,-173],[198000,-143],[198300,-103],[198600,-12],[198900,0],[199200,0],[199500,0],[199800,0],[200100,0],[200400,0],[200700,0],[201000,0],[201300,0],[201600,0],[201900,0],[202200,0],[202500,0],[202800,0],[203100,0],[203400,0],[203700,0],[204000,0],[204300,0],[204600,0],[204900,0],[205200,0],[205500,0],[205800,0],[206100,0],[206400,0],[206700,0],[207000,0],[207300,0],[207600,0],[207900,0],[208200,0],[208500,0],[208800,0],[209100,0],[209400,0],[209700,0],[210000,0],[210300,0],[210600,0],[210900,0],[211200,0],[211500,0],[211800,0],[212100,0],[212400,0],[212700,0],[213000,0],[213300,0],[213600,0],[213900,0],[214200,0],[214500,0],[214800,0],[215100,0],[215400,0],[215700,0],[216000,0],[216300,0],[216600,0],[216900,0],[217200,0],[217500,0],[217800,0],[218100,0],[218400,0],[218700,0],[219000,0],[219300,0],[219600,0],[219900,0],[220200,0],[220500,0],[220800,0],[221100,0],[221400,0],[221700,0],[222000,0],[222300,0],[222600,0],[222900,0],[223200,0],[223500,0],[223800,0],[224100,0],[224400,0],[224700,0],[225000,0],[225300,0],[225600,0],[225900,0],[226200,0],[226500,0],[226800,0],[227100,0],[227400,0],[227700,0],[228000,0],[228300,0],[228600,0],[228900,0],[229200,0],[229500,0],[229800,0],[230100,0],[230400,0],[230700,0],[231000,0],[231300,0],[231600,0],[231900,0],[232200,0],[232500,0],[232800,0],[233100,0],[233400,0],[233700,0],[234000,0],[234300,0],[234600,0],[234900,0],[235200,0],[235500,0],[235800,0],[236100,0],[236400,0],[236700,0],[237000,0],[237300,0],[237600,0],[237900,0],[238200,0],[238500,0],[238800,0],[239100,0],[239400,0],[239700,0],[240000,0],[240300,0],[240600,0],[240900,0],[241200,0],[241500,0],[241800,0],[242100,0],[242400,0],[242700,0],[243000,0],[243300,0],[243600,0],[243900,0],[244200,0],[244500,0],[244800,0],[245100,-6],[245400,0],[245700,0],[246000,0],[246300,0],[246600,0],[246900,-1],[247200,0],[247500,0],[247800,0],[248100,0],[248400,0],[248700,0],[249000,0],[249300,0],[249600,-26],[249900,-64],[250200,-158],[250500,-295],[250800,-428],[251100,-327],[251400,-191],[251700,-107],[252000,-62],[252300,-39],[252600,-4],[252900,-84],[253200,-86],[253500,-79],[253800,-80],[254100,-299],[254400,-958],[254700,-1882],[255000,-3410],[255300,-2715],[255600,-3464],[255900,-2371],[256200,-4486],[256500,-4619],[256800,-4802],[257100,-4955],[257400,-3902],[257700,-4411],[258000,-4423],[258300,-5487],[258600,-5974]],"meter_w":[[-300,-52],[0,42],[300,-237],[600,-416],[900,78],[1200,12],[1500,17],[1800,10],[2100,-16],[2400,-29],[2700,7],[3000,2],[3300,-14],[3600,-210],[3900,-499],[4200,35],[4500,-32],[4800,-1193],[5100,83],[5400,-27],[5700,-45],[6000,-232],[6300,-171],[6600,109],[6900,85],[7200,-38],[7500,-19],[7800,-45],[8100,-2],[8400,-33],[8700,21],[9000,264],[9300,55],[9600,1],[9900,35],[10200,10],[10500,-60],[10800,-672],[11100,-592],[11400,-84],[11700,-104],[12000,-26],[12300,-352],[12600,-326],[12900,47],[13200,10],[13500,9],[13800,3791],[14100,4332],[14400,4290],[14700,1867],[15000,1482],[15300,1259],[15600,10],[15900,-700],[16200,-2482],[16500,-2455],[16800,-1129],[17100,-3415],[17400,-3849],[17700,-2836],[18000,-3252],[18300,-888],[18600,728],[18900,701],[19200,359],[19500,-299],[19800,70],[20100,191],[20400,-21],[20700,122],[21000,1564],[21300,65],[21600,3562],[21900,2163],[22200,1956],[22500,2214],[22800,1017],[23100,1524],[23400,3600],[23700,2331],[24000,2326],[24300,1192],[24600,1152],[24900,1206],[25200,1238],[25500,-93],[25800,-15],[26100,486],[26400,58],[26700,533],[27000,153],[27300,64],[27600,3],[27900,26],[28200,49],[28500,-1],[28800,73],[29100,-99],[29400,-23],[29700,21],[30000,9],[30300,-9],[30600,-10],[30900,7],[31200,56],[31500,-12],[31800,12],[32100,-101],[32400,55],[32700,5],[33000,-5],[33300,10],[33600,-9],[33900,-49],[34200,-33],[34500,7],[34800,2],[35100,18],[35400,-16],[35700,-24],[36000,-45],[36300,-34],[36600,-23],[36900,10],[37200,-1],[37500,14],[37800,-18],[38100,-6],[38400,-30],[38700,-46],[39000,-50],[39300,4],[39600,2],[39900,8],[40200,-9],[40500,-25],[40800,-33],[41100,-33],[41400,-39],[41700,-35],[42000,-38],[42300,-40],[42600,-31],[42900,-6],[43200,7],[43500,5],[43800,-5],[44100,-7],[44400,-5],[44700,-1],[45000,-5],[45300,298],[45600,417],[45900,406],[46200,54],[46500,11],[46800,-31],[47100,37],[47400,-1],[47700,6],[48000,317],[48300,331],[48600,379],[48900,384],[49200,381],[49500,344],[49800,314],[50100,342],[50400,361],[50700,335],[51000,306],[51300,500],[51600,3933],[51900,4151],[52200,4322],[52500,8685],[52800,8707],[53100,9007],[53400,1006],[53700,549],[54000,572],[54300,638],[54600,627],[54900,680],[55200,626],[55500,573],[55800,544],[56100,7012],[56400,8150],[56700,4493],[57000,1034],[57300,685],[57600,690],[57900,631],[58200,571],[58500,569],[58800,3873],[59100,4322],[59400,4323],[59700,9218],[60000,9114],[60300,3231],[60600,855],[60900,335],[61200,310],[61500,923],[61800,993],[62100,768],[62400,3656],[62700,4332],[63000,4327],[63300,2509],[63600,7922],[63900,8199],[64200,2110],[64500,1838],[64800,1934],[65100,4321],[65400,4322],[65700,4320],[66000,9017],[66300,8995],[66600,8880],[66900,4942],[67200,4330],[67500,4326],[67800,8620],[68100,10412],[68400,10736],[68700,11445],[69000,11897],[69300,11700],[69600,5442],[69900,4301],[70200,3778],[70500,919],[70800,852],[71100,937],[71400,881],[71700,933],[72000,2188],[72300,174],[72600,-11],[72900,58],[73200,146],[73500,-32],[73800,-25],[74100,29],[74400,0],[74700,-9],[75000,-20],[75300,31],[75600,31],[75900,9],[76200,-28],[76500,12],[76800,13],[77100,-32],[77400,22],[77700,-9],[78000,-29],[78300,9],[78600,-17],[78900,-6],[79200,-17],[79500,-33],[79800,-4],[80100,8],[80400,-140],[80700,121],[81000,466],[81300,-2041],[81600,-3222],[81900,-3013],[82200,-1098],[82500,-874],[82800,-442],[83100,-475],[83400,-721],[83700,-1055],[84000,-1258],[84300,-897],[84600,-98],[84900,4],[85200,28],[85500,-21],[85800,18],[86100,-21],[86400,29],[86700,-16],[87000,19],[87300,-42],[87600,1189],[87900,415],[88200,-165],[88500,220],[88800,64],[89100,5],[89400,-289],[89700,81],[90000,522],[90300,1106],[90600,83],[90900,-37],[91200,22],[91500,-9],[91800,32],[92100,-72],[92400,12],[92700,-31],[93000,-28],[93300,13],[93600,4],[93900,-42],[94200,16],[94500,4],[94800,14],[95100,-52],[95400,-24],[95700,14],[96000,-15],[96300,-41],[96600,3],[96900,29],[97200,33],[97500,-36],[97800,6],[98100,-20],[98400,-22],[98700,21],[99000,-4],[99300,9],[99600,-1],[99900,-45],[100200,1596],[100500,1466],[100800,248],[101100,-599],[101400,-463],[101700,-289],[102000,-276],[102300,-241],[102600,-484],[102900,-589],[103200,-447],[103500,-353],[103800,-553],[104100,-221],[104400,-414],[104700,-204],[105000,77],[105300,235],[105600,110],[105900,-121],[106200,-12],[106500,-4],[106800,69],[107100,340],[107400,365],[107700,201],[108000,-24],[108300,18],[108600,123],[108900,806],[109200,1550],[109500,3518],[109800,943],[110100,107],[110400,24],[110700,-4],[111000,1139],[111300,437],[111600,509],[111900,56],[112200,-19],[112500,36],[112800,3],[113100,-4],[113400,-1],[113700,-17],[114000,-12],[114300,10],[114600,-8],[114900,21],[115200,-9],[115500,67],[115800,-97],[116100,-10],[116400,85],[116700,-8],[117000,-70],[117300,-16],[117600,8],[117900,71],[118200,-12],[118500,-73],[118800,-8],[119100,-9],[119400,36],[119700,-9],[120000,-9],[120300,5],[120600,26],[120900,4],[121200,51],[121500,-35],[121800,-21],[122100,-68],[122400,-11],[122700,27],[123000,3],[123300,6],[123600,-25],[123900,19],[124200,11],[124500,-2],[124800,-20],[125100,22],[125400,-1],[125700,-11],[126000,17],[126300,17],[126600,22],[126900,16],[127200,-1],[127500,24],[127800,-13],[128100,333],[128400,413],[128700,434],[129000,68],[129300,-37],[129600,19],[129900,471],[130200,488],[130500,388],[130800,546],[131100,788],[131400,750],[131700,657],[132000,748],[132300,701],[132600,701],[132900,685],[133200,452],[133500,507],[133800,463],[134100,455],[134400,447],[134700,445],[135000,527],[135300,628],[135600,593],[135900,465],[136200,369],[136500,414],[136800,483],[137100,521],[137400,434],[137700,488],[138000,536],[138300,571],[138600,577],[138900,829],[139200,726],[139500,736],[139800,604],[140100,672],[140400,697],[140700,801],[141000,493],[141300,522],[141600,487],[141900,443],[142200,360],[142500,407],[142800,450],[143100,518],[143400,427],[143700,516],[144000,510],[144300,467],[144600,402],[144900,390],[145200,599],[145500,865],[145800,749],[146100,687],[146400,695],[146700,747],[147000,698],[147300,642],[147600,744],[147900,528],[148200,491],[148500,552],[148800,1048],[149100,1013],[149400,1081],[149700,1006],[150000,1091],[150300,1135],[150600,1185],[150900,710],[151200,411],[151500,767],[151800,723],[152100,657],[152400,775],[152700,806],[153000,781],[153300,715],[153600,393],[153900,425],[154200,442],[154500,445],[154800,504],[155100,565],[155400,567],[155700,487],[156000,398],[156300,489],[156600,686],[156900,1361],[157200,1642],[157500,2672],[157800,1263],[158100,1108],[158400,1722],[158700,1227],[159000,990],[159300,1007],[159600,123],[159900,188],[160200,1077],[160500,1276],[160800,1158],[161100,1114],[161400,126],[161700,261],[162000,1155],[162300,142],[162600,286],[162900,1266],[163200,129],[163500,188],[163800,262],[164100,-31],[164400,-255],[164700,-706],[165000,-1008],[165300,-1625],[165600,-2807],[165900,-2057],[166200,-2214],[166500,-2437],[166800,-2764],[167100,-2909],[167400,-2999],[167700,-3161],[168000,-3490],[168300,-3650],[168600,-3804],[168900,-3938],[169200,-4192],[169500,-4282],[169800,-4339],[170100,-4417],[170400,-4267],[170700,-4582],[171000,-4650],[171300,-4201],[171600,-3391],[171900,-1837],[172200,-1425],[172500,-1432],[172800,-1568],[173100,-1992],[173400,-2306],[173700,-2552],[174000,-1457],[174300,-1488],[174600,-1330],[174900,-2032],[175200,-881],[175500,-8],[175800,-3],[176100,-10],[176400,-6],[176700,-7],[177000,6],[177300,-1],[177600,14],[177900,2],[178200,0],[178500,-2],[178800,-8],[179100,-8],[179400,-9],[179700,3],[180000,-5],[180300,-9],[180600,-5],[180900,-6],[181200,-3],[181500,-5],[181800,-2],[182100,0],[182400,-1],[182700,-7],[183000,6],[183300,-5],[183600,-3],[183900,-12],[184200,-5],[184500,8],[184800,-9],[185100,-2],[185400,-6],[185700,5],[186000,-5],[186300,-11],[186600,1908],[186900,2822],[187200,2703],[187500,-45],[187800,-127],[188100,24],[188400,-150],[188700,-202],[189000,-327],[189300,-197],[189600,-132],[189900,12],[190200,14],[190500,2],[190800,-3],[191100,-4],[191400,-16],[191700,-10],[192000,-6],[192300,-27],[192600,-46],[192900,-14],[193200,32],[193500,19],[193800,204],[194100,400],[194400,582],[194700,843],[195000,858],[195300,724],[195600,45],[195900,58],[196200,5],[196500,1015],[196800,2061],[197100,2181],[197400,353],[197700,-20],[198000,-18],[198300,2135],[198600,2514],[198900,4799],[199200,3526],[199500,2693],[199800,3408],[200100,3461],[200400,2656],[200700,2392],[201000,241],[201300,1],[201600,-3],[201900,-18],[202200,14],[202500,-21],[202800,-15],[203100,17],[203400,-5],[203700,-16],[204000,75],[204300,-46],[204600,17],[204900,65],[205200,4],[205500,919],[205800,828],[206100,845],[206400,2085],[206700,2475],[207000,809],[207300,687],[207600,774],[207900,1059],[208200,967],[208500,963],[208800,883],[209100,730],[209400,540],[209700,2076],[210000,2371],[210300,1398],[210600,438],[210900,396],[211200,579],[211500,408],[211800,399],[212100,347],[212400,360],[212700,402],[213000,400],[213300,418],[213600,707],[213900,613],[214200,578],[214500,575],[214800,582],[215100,597],[215400,659],[215700,701],[216000,712],[216300,634],[216600,495],[216900,309],[217200,310],[217500,309],[217800,306],[218100,408],[218400,436],[218700,408],[219000,422],[219300,483],[219600,317],[219900,542],[220200,626],[220500,609],[220800,690],[221100,805],[221400,756],[221700,629],[222000,583],[222300,585],[222600,3862],[222900,4320],[223200,4318],[223500,3900],[223800,4014],[224100,4015],[224400,4284],[224700,4186],[225000,4175],[225300,4320],[225600,4337],[225900,4332],[226200,4330],[226500,4324],[226800,4319],[227100,4317],[227400,4322],[227700,4324],[228000,4328],[228300,4309],[228600,4298],[228900,4306],[229200,4305],[229500,4304],[229800,718],[230100,657],[230400,764],[230700,624],[231000,823],[231300,868],[231600,838],[231900,919],[232200,1086],[232500,957],[232800,782],[233100,733],[233400,737],[233700,787],[234000,927],[234300,1039],[234600,940],[234900,1002],[235200,1025],[235500,957],[235800,317],[236100,309],[236400,420],[236700,531],[237000,364],[237300,313],[237600,389],[237900,458],[238200,554],[238500,879],[238800,609],[239100,594],[239400,652],[239700,645],[240000,543],[240300,530],[240600,639],[240900,527],[241200,523],[241500,442],[241800,442],[242100,495],[242400,490],[242700,784],[243000,1047],[243300,1085],[243600,1061],[243900,1090],[244200,1171],[244500,1165],[244800,1993],[245100,1462],[245400,1290],[245700,1192],[246000,117],[246300,-19],[246600,-32],[246900,18],[247200,-35],[247500,0],[247800,-3],[248100,5],[248400,-5],[248700,-2664],[249000,-2951],[249300,-2942],[249600,-2799],[249900,-2830],[250200,-3420],[250500,-3749],[250800,-3800],[251100,-3332],[251400,-2978],[251700,-2825],[252000,-2626],[252300,-2560],[252600,-2620],[252900,-2963],[253200,-1700],[253500,-1393],[253800,-1446],[254100,-42],[254400,-131],[254700,-330],[255000,-1786],[255300,-1460],[255600,-2009],[255900,-2617],[256200,-5170],[256500,-5262],[256800,-2093],[257100,-1672],[257400,-1450],[257700,-1500],[258000,-1415],[258300,-1366],[258600,-6610]]},"t0_ms":1791705900000} \ No newline at end of file diff --git a/appleApp/FTWUITests/HistoryTour.swift b/appleApp/FTWUITests/HistoryTour.swift new file mode 100644 index 0000000..9208a96 --- /dev/null +++ b/appleApp/FTWUITests/HistoryTour.swift @@ -0,0 +1,73 @@ +import XCTest + +/// A node from Live to Today: a finger on the live trace, then the same +/// finger on Today, the chart small, a second series added by its chip, +/// then the chart grown to the whole screen and scrubbed there. For +/// screenshots and recordings. Skipped unless asked for: +/// +/// TEST_RUNNER_FTW_HISTORY=1 xcodebuild test … -only-testing:FTWUITests/HistoryTour +final class HistoryTour: XCTestCase { + @MainActor + func testCompactThenEnlarged() throws { + let env = ProcessInfo.processInfo.environment + try XCTSkipUnless(env["FTW_HISTORY"] == "1", "Set TEST_RUNNER_FTW_HISTORY=1 to drive the history.") + let app = XCUIApplication() + app.launchArguments = ["-FTWDemo", "YES", "-FTWNode", env["FTW_NODE"] ?? "battery", "-FTWPeriod", "live", "-AppleLanguages", "(en)", "-AppleLocale", "en_US"] + app.launch() + // Live first, with a finger on it, then Today in the same material. + let live = app.descendants(matching: .any)["node.live.chart"].firstMatch + XCTAssertTrue(live.waitForExistence(timeout: 15)) + Thread.sleep(forTimeInterval: 8) + shoot(app, "live") + drag(live) + app.segmentedControls["node.period"].buttons["Today"].tap() + let chart = app.descendants(matching: .any)["node.period.chart"].firstMatch + XCTAssertTrue(chart.waitForExistence(timeout: 15)) + Thread.sleep(forTimeInterval: 2) + shoot(app, "compact") + drag(chart) + // A second series, then a third, by their chips. + let solar = app.buttons["chart.series.solar"] + XCTAssertTrue(solar.waitForExistence(timeout: 5)) + solar.tap() + Thread.sleep(forTimeInterval: 1.2) + shoot(app, "compact-two") + app.buttons["chart.series.grid"].tap() + Thread.sleep(forTimeInterval: 1.2) + // A finger across the small chart. + drag(chart) + // Grown to the whole screen out of the small one. + app.buttons["node.chart.enlarge"].tap() + let close = app.buttons["node.chart.close"] + XCTAssertTrue(close.waitForExistence(timeout: 5)) + Thread.sleep(forTimeInterval: 1.5) + shoot(app, "enlarged") + drag(app.descendants(matching: .any)["node.period.chart"].firstMatch) + app.segmentedControls["node.chart.period"].buttons["48 h"].tap() + Thread.sleep(forTimeInterval: 1.5) + shoot(app, "enlarged-48h") + // Back to one series, then shut. + app.buttons["chart.series.grid"].firstMatch.tap() + app.buttons["chart.series.solar"].firstMatch.tap() + Thread.sleep(forTimeInterval: 1) + close.tap() + Thread.sleep(forTimeInterval: 1.5) + shoot(app, "compact-again") + } + + @MainActor + private func drag(_ element: XCUIElement) { + let from = element.coordinate(withNormalizedOffset: CGVector(dx: 0.1, dy: 0.5)) + let to = element.coordinate(withNormalizedOffset: CGVector(dx: 0.8, dy: 0.5)) + from.press(forDuration: 0.2, thenDragTo: to, withVelocity: .slow, thenHoldForDuration: 1) + Thread.sleep(forTimeInterval: 0.8) + } + + @MainActor + private func shoot(_ app: XCUIApplication, _ name: String) { + let shot = XCTAttachment(screenshot: app.screenshot()) + shot.name = name + shot.lifetime = .keepAlways + add(shot) + } +} diff --git a/appleApp/FTWUITests/NowFitsTests.swift b/appleApp/FTWUITests/NowFitsTests.swift index fb50357..974461d 100644 --- a/appleApp/FTWUITests/NowFitsTests.swift +++ b/appleApp/FTWUITests/NowFitsTests.swift @@ -142,6 +142,8 @@ final class NowFitsTests: XCTestCase { let app = launchDemo() app.buttons["now.node.battery"].tap() XCTAssertTrue(app.navigationBars["Battery"].waitForExistence(timeout: 5)) + // The view opens on the timebase this phone chose last; Live is one tap. + app.segmentedControls["node.period"].buttons["Live"].tap() XCTAssertTrue(app.descendants(matching: .any)["node.live.chart"].waitForExistence(timeout: 5)) let ahead = app.segmentedControls["node.period"].buttons["Ahead"] XCTAssertTrue(ahead.isHittable)