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..65566de 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 @@ -117,6 +129,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` @@ -206,7 +229,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 +296,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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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) 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/) |