diff --git a/.github/workflows/apple.yml b/.github/workflows/apple.yml new file mode 100644 index 0000000..1de09e0 --- /dev/null +++ b/.github/workflows/apple.yml @@ -0,0 +1,65 @@ +name: Apple + +on: + push: + branches: [main] + paths: ['appleApp/**', 'protocol/**', '.github/workflows/apple.yml'] + pull_request: + paths: ['appleApp/**', 'protocol/**', '.github/workflows/apple.yml'] + +concurrency: + group: apple-${{ github.ref }} + cancel-in-progress: true + +jobs: + kit-linux: + name: FTWKit on Linux + runs-on: ubuntu-latest + container: swift:6.1 + steps: + - uses: actions/checkout@v4 + - name: Test + working-directory: appleApp/FTWKit + run: swift test + + apple: + name: FTWKit and the app on macOS + runs-on: macos-15 + steps: + - uses: actions/checkout@v4 + - name: Use the newest Xcode on the runner + run: | + latest=$(ls -d /Applications/Xcode_*.app | sort -V | tail -1) + sudo xcode-select -s "$latest" + xcodebuild -version + - name: Test FTWKit with CryptoKit + working-directory: appleApp/FTWKit + run: swift test + # Unsigned builds: they prove the app compiles for both platforms. + # Signing, passkeys and the camera need a team and a device. + - name: Build the app for the iOS Simulator + working-directory: appleApp + run: >- + xcodebuild build -quiet -project FTW.xcodeproj -scheme FTW + -destination 'generic/platform=iOS Simulator' + -derivedDataPath build/ios CODE_SIGNING_ALLOWED=NO + - name: Build the app for macOS + working-directory: appleApp + run: >- + xcodebuild build -quiet -project FTW.xcodeproj -scheme FTW + -destination 'generic/platform=macOS' + -derivedDataPath build/macos CODE_SIGNING_ALLOWED=NO + # For whoever reviews the screens: the demo, photographed per tab in + # the simulator. Evidence, not a gate, so a flaky simulator does not + # turn the build red. + - name: Photograph the demo in the iOS Simulator + continue-on-error: true + working-directory: appleApp + run: scripts/demo-screenshots.sh build/screenshots + - name: Keep the screenshots + if: always() + uses: actions/upload-artifact@v4 + with: + name: demo-screenshots + path: appleApp/build/screenshots + if-no-files-found: ignore diff --git a/.gitignore b/.gitignore index 6bfa9df..5373ebb 100644 --- a/.gitignore +++ b/.gitignore @@ -25,3 +25,10 @@ iosApp/Pods/ # Secrets *.jks keystore.properties + +# Swift package +appleApp/FTWKit/.build/ +appleApp/FTWKit/.swiftpm/ +appleApp/build/ +appleApp/FTW.xcodeproj/project.xcworkspace/xcuserdata/ +appleApp/FTW.xcodeproj/xcuserdata/ diff --git a/CLAUDE.md b/CLAUDE.md index a941e9e..121a73a 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1,6 +1,7 @@ # FTW native app — project guide -Kotlin Multiplatform shared logic. SwiftUI on iOS. Jetpack Compose on Android. +Pure Swift on iPhone, iPad and Mac: FTWKit plus SwiftUI in `appleApp/`. +Kotlin Multiplatform shared logic with Jetpack Compose on Android. Talks to an FTW box over an encrypted session; the box is the authority and this app is a cached projection of it. @@ -17,15 +18,22 @@ and useful notifications. Those goals do not remove the current native release gates below or claim features are present on either phone. Reuse Core's contracts and authority as native scope expands. -## Current v1 +## Current scope -Pair + Now only. Shipped on `main` as of 2026-08-22. Persist vault, site and -last readings on the phone. Cold start paints the cache, then reconnects -without a passkey. README Status lists what was proven and what is still open. +**Apple (`appleApp/`).** On 2026-09-25 Fredrik chose a pure Swift app for +iOS and macOS that covers every screen of the web app: Pair, Now, Plan, +History with daily energy, the charger sheet, and Box (access, +notifications, restart, the sealed copy, sign out). It derives the wrap key +with HKDF exactly as the web app does, so one passkey opens a home in both. +README Status lists what is proven and what still needs a device. -Do not add Energy / History / Plan / EV, commands, escrow, LAN, push, or store -listing until Pair + Now is solid on both phones, including wrap-key parity -with the web app. +**Android (`androidApp/`, `shared/`).** Pair + Now, shipped on `main` as of +2026-08-22. Do not add Energy / History / Plan / EV, commands, escrow, LAN, +push, or store listing on Android until Pair + Now is solid there, including +wrap-key parity with the web app. + +Both: persist vault, site and last readings on the phone. Cold start paints +the cache, then reconnects without a passkey. The protocol, the QR, the relay and the identity model are specified in [ftw-webapp](https://github.com/srcfl/ftw-webapp) `docs/architecture.md` and @@ -62,21 +70,24 @@ The protocol, the QR, the relay and the identity model are specified in ## Shared vs UI -`shared/` owns enrollment parse, rendezvous handles, Noise IK, frames, -session, vault wrap/unwrap, freshness and explanations. - -Platform UI owns the camera, the passkey ceremony, Keychain / Keystore, -and every pixel. +On Apple, `appleApp/FTWKit` owns everything that is not a pixel: enrollment +parse, rendezvous handles, Noise IK, frames, the relay and Noise carriers, +the session, vault wrap/unwrap, escrow, freshness, explanations and the +state each screen reads. It builds and tests on Linux too. `appleApp/FTW` +owns the camera, the passkey ceremony, the Keychain and every pixel. Keep +logic out of the views: if a sentence or a rule can be tested, it belongs +in FTWKit with a test. -Inject `PasskeyHost`, `KeyValueStore` and `SocketFactory`. Do not call -AuthenticationServices or Credential Manager from commonMain. iOS uses -Keychain. Android uses EncryptedSharedPreferences + a Keystore master key. +On Android, `shared/` owns the same logic in Kotlin. Inject `PasskeyHost`, +`KeyValueStore` and `SocketFactory`. Do not call Credential Manager from +commonMain. Android uses EncryptedSharedPreferences + a Keystore master key. ## Crypto Noise_IK_25519_ChaChaPoly_SHA256, Cacophony-tested, must stay byte-identical to the TypeScript client and the Go box. Do not swap the primitives for a -library that has not passed `NoiseTest`. +library that has not passed `NoiseTest` (Kotlin) or `NoiseTests` (Swift). +FTWKit uses CryptoKit on Apple platforms and swift-crypto on Linux. ## RP ID @@ -86,8 +97,13 @@ strands every passkey. ## Tests ```bash -./gradlew :shared:jvmTest +./gradlew :shared:jvmTest # Android shared logic +cd appleApp/FTWKit && swift test # Apple logic, on macOS or Linux ``` +The Apple workflow also builds the app, unsigned, for the iOS Simulator and +for macOS. Review UI changes in the simulator or on a device; reading the +source is not enough. + Green before every handoff. New protocol code needs a vector, not only a round-trip against itself. diff --git a/README.md b/README.md index 5ae3564..a18783d 100644 --- a/README.md +++ b/README.md @@ -2,9 +2,10 @@ Your home's energy, on the phone. -Native iOS (SwiftUI) and Android (Jetpack Compose). Shared logic is Kotlin -Multiplatform: pairing, passkeys, Noise, the relay, the session. The box at -home is the record. This app is a cached projection of it. The cloud is blind. +Pure Swift on iPhone, iPad and Mac (FTWKit and SwiftUI). Jetpack Compose on +Android, with its logic in Kotlin Multiplatform. Both carry pairing, +passkeys, Noise, the relay and the session. The box at home is the record. +This app is a cached projection of it. The cloud is blind. Not a wrap of the [web app](https://github.com/srcfl/ftw-webapp). Same protocol, same QR, same relay, same RP ID (`app.ftw.energy`). @@ -25,34 +26,82 @@ See [CONTRIBUTING.md](CONTRIBUTING.md). ## Shape ``` -SwiftUI / Compose - │ - ▼ - shared (KMP) — enrollment, vault, Noise IK, frames, session, relay - │ - ▼ - wss://relay.ftw.energy (encrypted) - │ - ▼ - FTW box +SwiftUI (iOS, macOS) Compose (Android) + │ │ + ▼ ▼ + FTWKit (Swift) shared (KMP) + enrollment, vault, escrow, enrollment, vault, + Noise IK, frames, session, Noise IK, frames, + relay, screen state session, relay + │ │ + └─────────────┬──────────────┘ + ▼ + wss://relay.ftw.energy (encrypted) + │ + ▼ + FTW box ``` Two taps: scan the QR on the box, Face ID / biometrics, the house. -## Status (2026-08-22) +## Status: Apple (2026-09-25) + +The Apple app is pure Swift and covers every screen of the web app. The +wrap key comes from HKDF over the PRF output, exactly as in the web app, so +one passkey opens a home in both. Native pairings made before this change +scan the QR again. + +**In the app** + +- Pair: camera QR, a picture of the QR on a Mac, passkey recovery from the + sealed copy, a link arriving from outside shown before it is trusted, and + the live demo against a simulated box. +- Now: one sentence, the energy flow, the price card with the cheapest two + hours, what FTW does next, today's totals and savings, the fuse, a live + line per part of the house, and the charger sheet (charge now, pause, + battery level, goal, spare solar only, home battery boost, car battery + size, charging windows). +- Plan: the headline, how the home is run, prices for today and tomorrow, + and the next twelve hours. +- History: energy per day for today, 7 and 30 days, and power over 24 h to + a year from cached tiles. +- Box: identity, who can see this home and viewer invites, notification + rules and history, restart, the sealed copy, sign out. +- The freshness band above every screen, with carrier and source state kept + apart. A Mac also gets a menu bar glance. + +**Proven** -V1 is Pair + Now. That is on `main` as of 2026-08-22. Not a wrap of the web -app. Not Flutter, not React Native. +| Check | Result | +|---|---| +| `swift test` in `appleApp/FTWKit`, Linux (Swift 6.3) and macOS (CryptoKit) | 95 tests green | +| Cross implementation vectors from the web app and the box | Noise, frames, rendezvous handles, recovery blob, escrow ids and write keys, vault copy all match | +| Live box through the production relay | `hello_ok`, snapshot, `streaming`, history tiles | +| Unsigned app build in CI | iOS Simulator and macOS | + +**Needs a device or an owner decision** + +- Passkeys on a real phone or Mac need a signing team and an + `apple-app-site-association` file on `app.ftw.energy` that names the app + (`.energy.ftw.app` under `webcredentials` and `applinks`). +- Nobody has reviewed the screens in a simulator or on a device yet. +- Notifications: the box reaches phones through web push and ntfy. This app + manages the box's rules and shows what was sent, but cannot receive them + until the box and relay learn to send to APNs. + +## Status: Android (2026-08-22) -**In the apps today** +V1 is Pair + Now. Not a wrap of the web app. Not Flutter, not React Native. + +**In the app today** - Scan or paste a v2 pairing QR (`https://app.ftw.energy/p#v2.…`). - One passkey prompt at enroll. RP ID `app.ftw.energy`. PRF salt `ftw.prf.v1.vault`. - Noise_IK_25519_ChaChaPoly_SHA256 to the box through `wss://relay.ftw.energy`. - Now shows headline plus grid / solar / battery / house from frozen field ids. -- Vault, site and last readings live in iOS Keychain / - Android EncryptedSharedPreferences. Cold start paints from cache, then - reconnects without Face ID. Forget wipes the store. +- Vault, site and last readings live in Android EncryptedSharedPreferences. + Cold start paints from cache, then reconnects without biometrics. Forget + wipes the store. **Proven here** @@ -60,32 +109,37 @@ app. Not Flutter, not React Native. |---|---| | `./gradlew :shared:jvmTest` | Green | | Live box e2e (`127.0.0.1:18080` + production relay) | `hello_ok` + snapshot, phase `streaming` | -| iOS Simulator (iPhone 17, iOS 26.5) | Built and launched | | Android emulator `FTW_Phone` (API 35 ARM64) | APK installed, Pair shown twice | Passkey PRF cannot run on the JVM. Live e2e uses a local wrapping key for the ceremony and the real Noise / relay / box path. The Android emulator has no camera feed — paste the pairing link. -**Not v1 (do not start these next)** +**Not v1 on Android (do not start these next)** Energy, History, Plan, EV, commands, escrow restore, spoken codes, LAN, -WebRTC, push, App Store / Play listing. +WebRTC, push, Play listing. **Known holes** - `srcState` should follow the Now fields' `srcId` in the dict, not every driver on the site. -- Wrap key is raw PRF bytes, not the web app's HKDF. A native vault will not - open in the PWA, and the other way around. -- `PasskeyHost.enroll` from Kotlin still blocks. The UIs call the async - ceremony and skip that path. +- Wrap key is raw PRF bytes, not the web app's HKDF. An Android vault will + not open in the PWA, and the other way around. +- `PasskeyHost.enroll` from Kotlin still blocks. The UI calls the async + ceremony and skips that path. - Field ids in `Explanation.kt` are still hand-written; they should come from `protocol/registry.yaml`. ## Tests -JDK 21. +Apple, on macOS or Linux (Swift 6.1 or newer): + +```bash +cd appleApp/FTWKit && swift test +``` + +Android, JDK 21. ```bash export JAVA_HOME="$(brew --prefix openjdk@21)/libexec/openjdk.jdk/Contents/Home" @@ -116,10 +170,10 @@ snapshot that includes the frozen field ids. ## Native apps -iOS: open `iosApp/iosApp.xcodeproj`. SwiftUI Pair (camera QR + paste) and Now. -Xcode 16+, iOS 18 for passkey PRF. A Run Script build phase compiles the -Shared framework with -`./gradlew :shared:embedAndSignAppleFrameworkForXcode`. +iOS and macOS: open `appleApp/FTW.xcodeproj`, pick your team under Signing, +and run the FTW scheme on a simulator, a device or My Mac. Xcode 16 or +newer; iOS 18 and macOS 15 for passkey PRF. The demo on the pairing screen +runs without a box, a passkey or a network. Android: `./gradlew :androidApp:assembleDebug` (minSdk 28). Pair uses CameraX + ML Kit for the QR. Passkeys go through Credential Manager. @@ -140,9 +194,10 @@ wrapping copy so Now paints without a passkey prompt. | Path | What | |---|---| -| `shared/` | KMP: identity, crypto, protocol, relay, session | +| `appleApp/FTWKit/` | Swift: identity, crypto, protocol, relay, session, screen state | +| `appleApp/FTW/` | SwiftUI for iOS and macOS | +| `shared/` | KMP for Android: identity, crypto, protocol, relay, session | | `androidApp/` | Compose UI | -| `iosApp/` | SwiftUI UI | | `protocol/registry.yaml` | Names shared with the box | | `scripts/e2e-ftw.sh` | Live box e2e | diff --git a/iosApp/iosApp/iosApp.entitlements b/appleApp/FTW-iOS.entitlements similarity index 100% rename from iosApp/iosApp/iosApp.entitlements rename to appleApp/FTW-iOS.entitlements diff --git a/appleApp/FTW-macOS.entitlements b/appleApp/FTW-macOS.entitlements new file mode 100644 index 0000000..fb1a0a4 --- /dev/null +++ b/appleApp/FTW-macOS.entitlements @@ -0,0 +1,19 @@ + + + + + com.apple.developer.associated-domains + + webcredentials:app.ftw.energy + applinks:app.ftw.energy + + com.apple.security.app-sandbox + + com.apple.security.device.camera + + com.apple.security.files.user-selected.read-only + + com.apple.security.network.client + + + diff --git a/appleApp/FTW.xcodeproj/project.pbxproj b/appleApp/FTW.xcodeproj/project.pbxproj new file mode 100644 index 0000000..31c513a --- /dev/null +++ b/appleApp/FTW.xcodeproj/project.pbxproj @@ -0,0 +1,322 @@ +// !$*UTF8*$! +{ + archiveVersion = 1; + classes = { + }; + objectVersion = 77; + objects = { + +/* Begin PBXBuildFile section */ + F7A000000000000000000014 /* FTWKit in Frameworks */ = {isa = PBXBuildFile; productRef = F7A000000000000000000016 /* FTWKit */; }; +/* End PBXBuildFile section */ + +/* Begin PBXFileReference section */ + F7A000000000000000000005 /* FTW.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = FTW.app; sourceTree = BUILT_PRODUCTS_DIR; }; + F7A000000000000000000006 /* FTW-iOS.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = "FTW-iOS.entitlements"; sourceTree = ""; }; + F7A000000000000000000007 /* FTW-macOS.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = "FTW-macOS.entitlements"; sourceTree = ""; }; +/* End PBXFileReference section */ + +/* Begin PBXFileSystemSynchronizedRootGroup section */ + F7A000000000000000000003 /* FTW */ = { + isa = PBXFileSystemSynchronizedRootGroup; + path = FTW; + sourceTree = ""; + }; +/* End PBXFileSystemSynchronizedRootGroup section */ + +/* Begin PBXFrameworksBuildPhase section */ + F7A000000000000000000012 /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + F7A000000000000000000014 /* FTWKit in Frameworks */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXFrameworksBuildPhase section */ + +/* Begin PBXGroup section */ + F7A000000000000000000002 = { + isa = PBXGroup; + children = ( + F7A000000000000000000003 /* FTW */, + F7A000000000000000000006 /* FTW-iOS.entitlements */, + F7A000000000000000000007 /* FTW-macOS.entitlements */, + F7A000000000000000000004 /* Products */, + ); + sourceTree = ""; + }; + F7A000000000000000000004 /* Products */ = { + isa = PBXGroup; + children = ( + F7A000000000000000000005 /* FTW.app */, + ); + name = Products; + sourceTree = ""; + }; +/* End PBXGroup section */ + +/* Begin PBXNativeTarget section */ + F7A000000000000000000010 /* FTW */ = { + isa = PBXNativeTarget; + buildConfigurationList = F7A000000000000000000023 /* Build configuration list for PBXNativeTarget "FTW" */; + buildPhases = ( + F7A000000000000000000011 /* Sources */, + F7A000000000000000000012 /* Frameworks */, + F7A000000000000000000013 /* Resources */, + ); + buildRules = ( + ); + dependencies = ( + ); + fileSystemSynchronizedGroups = ( + F7A000000000000000000003 /* FTW */, + ); + name = FTW; + packageProductDependencies = ( + F7A000000000000000000016 /* FTWKit */, + ); + productName = FTW; + productReference = F7A000000000000000000005 /* FTW.app */; + productType = "com.apple.product-type.application"; + }; +/* End PBXNativeTarget section */ + +/* Begin PBXProject section */ + F7A000000000000000000001 /* Project object */ = { + isa = PBXProject; + attributes = { + BuildIndependentTargetsInParallel = 1; + LastSwiftUpdateCheck = 1600; + LastUpgradeCheck = 1600; + TargetAttributes = { + F7A000000000000000000010 = { + CreatedOnToolsVersion = 16.0; + }; + }; + }; + buildConfigurationList = F7A000000000000000000020 /* Build configuration list for PBXProject "FTW" */; + developmentRegion = en; + hasScannedForEncodings = 0; + knownRegions = ( + en, + Base, + ); + mainGroup = F7A000000000000000000002; + minimizedProjectReferenceProxies = 1; + packageReferences = ( + F7A000000000000000000015 /* XCLocalSwiftPackageReference "FTWKit" */, + ); + preferredProjectObjectVersion = 77; + productRefGroup = F7A000000000000000000004 /* Products */; + projectDirPath = ""; + projectRoot = ""; + targets = ( + F7A000000000000000000010 /* FTW */, + ); + }; +/* End PBXProject section */ + +/* Begin PBXResourcesBuildPhase section */ + F7A000000000000000000013 /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXResourcesBuildPhase section */ + +/* Begin PBXSourcesBuildPhase section */ + F7A000000000000000000011 /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXSourcesBuildPhase section */ + +/* Begin XCBuildConfiguration section */ + F7A000000000000000000021 /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; + CLANG_ANALYZER_NONNULL = YES; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++20"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + COPY_PHASE_STRIP = NO; + DEBUG_INFORMATION_FORMAT = dwarf; + ENABLE_STRICT_OBJC_MSGSEND = YES; + ENABLE_TESTABILITY = YES; + ENABLE_USER_SCRIPT_SANDBOXING = YES; + GCC_C_LANGUAGE_STANDARD = gnu17; + GCC_DYNAMIC_NO_PIC = NO; + GCC_NO_COMMON_BLOCKS = YES; + GCC_OPTIMIZATION_LEVEL = 0; + GCC_PREPROCESSOR_DEFINITIONS = ( + "DEBUG=1", + "$(inherited)", + ); + IPHONEOS_DEPLOYMENT_TARGET = 18.0; + LOCALIZATION_PREFERS_STRING_CATALOGS = YES; + MACOSX_DEPLOYMENT_TARGET = 15.0; + MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE; + MTL_FAST_MATH = YES; + ONLY_ACTIVE_ARCH = YES; + SWIFT_ACTIVE_COMPILATION_CONDITIONS = "DEBUG $(inherited)"; + SWIFT_OPTIMIZATION_LEVEL = "-Onone"; + }; + name = Debug; + }; + F7A000000000000000000022 /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; + CLANG_ANALYZER_NONNULL = YES; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++20"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + COPY_PHASE_STRIP = NO; + DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; + ENABLE_NS_ASSERTIONS = NO; + ENABLE_STRICT_OBJC_MSGSEND = YES; + ENABLE_USER_SCRIPT_SANDBOXING = YES; + GCC_C_LANGUAGE_STANDARD = gnu17; + GCC_NO_COMMON_BLOCKS = YES; + IPHONEOS_DEPLOYMENT_TARGET = 18.0; + LOCALIZATION_PREFERS_STRING_CATALOGS = YES; + MACOSX_DEPLOYMENT_TARGET = 15.0; + MTL_ENABLE_DEBUG_INFO = NO; + MTL_FAST_MATH = YES; + SWIFT_COMPILATION_MODE = wholemodule; + }; + name = Release; + }; + F7A000000000000000000024 /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; + CODE_SIGN_ENTITLEMENTS = "FTW-iOS.entitlements"; + "CODE_SIGN_ENTITLEMENTS[sdk=macosx*]" = "FTW-macOS.entitlements"; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + ENABLE_HARDENED_RUNTIME = YES; + ENABLE_PREVIEWS = YES; + GENERATE_INFOPLIST_FILE = YES; + INFOPLIST_KEY_CFBundleDisplayName = FTW; + INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.utilities"; + INFOPLIST_KEY_NSCameraUsageDescription = "FTW uses the camera to read the pairing code from your box."; + INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES; + INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES; + INFOPLIST_KEY_UILaunchScreen_Generation = YES; + INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; + INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = UIInterfaceOrientationPortrait; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + ); + "LD_RUNPATH_SEARCH_PATHS[sdk=macosx*]" = ( + "$(inherited)", + "@executable_path/../Frameworks", + ); + MARKETING_VERSION = 0.1.0; + PRODUCT_BUNDLE_IDENTIFIER = energy.ftw.app; + PRODUCT_NAME = FTW; + SDKROOT = auto; + SUPPORTED_PLATFORMS = "iphoneos iphonesimulator macosx"; + SUPPORTS_MACCATALYST = NO; + SUPPORTS_MAC_DESIGNED_FOR_IPHONE_IPAD = NO; + SUPPORTS_XR_DESIGNED_FOR_IPHONE_IPAD = NO; + SWIFT_EMIT_LOC_STRINGS = YES; + SWIFT_VERSION = 6.0; + TARGETED_DEVICE_FAMILY = "1,2"; + }; + name = Debug; + }; + F7A000000000000000000025 /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; + CODE_SIGN_ENTITLEMENTS = "FTW-iOS.entitlements"; + "CODE_SIGN_ENTITLEMENTS[sdk=macosx*]" = "FTW-macOS.entitlements"; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + ENABLE_HARDENED_RUNTIME = YES; + ENABLE_PREVIEWS = YES; + GENERATE_INFOPLIST_FILE = YES; + INFOPLIST_KEY_CFBundleDisplayName = FTW; + INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.utilities"; + INFOPLIST_KEY_NSCameraUsageDescription = "FTW uses the camera to read the pairing code from your box."; + INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES; + INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES; + INFOPLIST_KEY_UILaunchScreen_Generation = YES; + INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; + INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = UIInterfaceOrientationPortrait; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + ); + "LD_RUNPATH_SEARCH_PATHS[sdk=macosx*]" = ( + "$(inherited)", + "@executable_path/../Frameworks", + ); + MARKETING_VERSION = 0.1.0; + PRODUCT_BUNDLE_IDENTIFIER = energy.ftw.app; + PRODUCT_NAME = FTW; + SDKROOT = auto; + SUPPORTED_PLATFORMS = "iphoneos iphonesimulator macosx"; + SUPPORTS_MACCATALYST = NO; + SUPPORTS_MAC_DESIGNED_FOR_IPHONE_IPAD = NO; + SUPPORTS_XR_DESIGNED_FOR_IPHONE_IPAD = NO; + SWIFT_EMIT_LOC_STRINGS = YES; + SWIFT_VERSION = 6.0; + TARGETED_DEVICE_FAMILY = "1,2"; + }; + name = Release; + }; +/* End XCBuildConfiguration section */ + +/* Begin XCConfigurationList section */ + F7A000000000000000000020 /* Build configuration list for PBXProject "FTW" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + F7A000000000000000000021 /* Debug */, + F7A000000000000000000022 /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; + F7A000000000000000000023 /* Build configuration list for PBXNativeTarget "FTW" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + F7A000000000000000000024 /* Debug */, + F7A000000000000000000025 /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; +/* End XCConfigurationList section */ + +/* Begin XCLocalSwiftPackageReference section */ + F7A000000000000000000015 /* XCLocalSwiftPackageReference "FTWKit" */ = { + isa = XCLocalSwiftPackageReference; + relativePath = FTWKit; + }; +/* End XCLocalSwiftPackageReference section */ + +/* Begin XCSwiftPackageProductDependency section */ + F7A000000000000000000016 /* FTWKit */ = { + isa = XCSwiftPackageProductDependency; + package = F7A000000000000000000015 /* XCLocalSwiftPackageReference "FTWKit" */; + productName = FTWKit; + }; +/* End XCSwiftPackageProductDependency section */ + }; + rootObject = F7A000000000000000000001 /* Project object */; +} diff --git a/appleApp/FTW.xcodeproj/project.xcworkspace/contents.xcworkspacedata b/appleApp/FTW.xcodeproj/project.xcworkspace/contents.xcworkspacedata new file mode 100644 index 0000000..919434a --- /dev/null +++ b/appleApp/FTW.xcodeproj/project.xcworkspace/contents.xcworkspacedata @@ -0,0 +1,7 @@ + + + + + diff --git a/iosApp/iosApp.xcodeproj/xcshareddata/xcschemes/iosApp.xcscheme b/appleApp/FTW.xcodeproj/xcshareddata/xcschemes/FTW.xcscheme similarity index 82% rename from iosApp/iosApp.xcodeproj/xcshareddata/xcschemes/iosApp.xcscheme rename to appleApp/FTW.xcodeproj/xcshareddata/xcschemes/FTW.xcscheme index 3657485..a66be0e 100644 --- a/iosApp/iosApp.xcodeproj/xcshareddata/xcschemes/iosApp.xcscheme +++ b/appleApp/FTW.xcodeproj/xcshareddata/xcschemes/FTW.xcscheme @@ -14,10 +14,10 @@ buildForAnalyzing = "YES"> + BlueprintName = "FTW" + ReferencedContainer = "container:FTW.xcodeproj"> @@ -44,10 +44,10 @@ runnableDebuggingMode = "0"> + BlueprintName = "FTW" + ReferencedContainer = "container:FTW.xcodeproj"> @@ -61,10 +61,10 @@ runnableDebuggingMode = "0"> + BlueprintName = "FTW" + ReferencedContainer = "container:FTW.xcodeproj"> diff --git a/appleApp/FTW/Assets.xcassets/AccentColor.colorset/Contents.json b/appleApp/FTW/Assets.xcassets/AccentColor.colorset/Contents.json new file mode 100644 index 0000000..fdca332 --- /dev/null +++ b/appleApp/FTW/Assets.xcassets/AccentColor.colorset/Contents.json @@ -0,0 +1,38 @@ +{ + "colors": [ + { + "idiom": "universal", + "color": { + "color-space": "srgb", + "components": { + "red": "0xDA", + "green": "0x7F", + "blue": "0x00", + "alpha": "1.000" + } + } + }, + { + "idiom": "universal", + "appearances": [ + { + "appearance": "luminosity", + "value": "dark" + } + ], + "color": { + "color-space": "srgb", + "components": { + "red": "0xFF", + "green": "0xAC", + "blue": "0x41", + "alpha": "1.000" + } + } + } + ], + "info": { + "author": "xcode", + "version": 1 + } +} \ No newline at end of file diff --git a/appleApp/FTW/Assets.xcassets/AppIcon.appiconset/Contents.json b/appleApp/FTW/Assets.xcassets/AppIcon.appiconset/Contents.json new file mode 100644 index 0000000..592f5b9 --- /dev/null +++ b/appleApp/FTW/Assets.xcassets/AppIcon.appiconset/Contents.json @@ -0,0 +1,74 @@ +{ + "images": [ + { + "filename": "icon-ios-1024.png", + "idiom": "universal", + "platform": "ios", + "size": "1024x1024" + }, + { + "filename": "icon-mac-16@1x.png", + "idiom": "mac", + "scale": "1x", + "size": "16x16" + }, + { + "filename": "icon-mac-16@2x.png", + "idiom": "mac", + "scale": "2x", + "size": "16x16" + }, + { + "filename": "icon-mac-32@1x.png", + "idiom": "mac", + "scale": "1x", + "size": "32x32" + }, + { + "filename": "icon-mac-32@2x.png", + "idiom": "mac", + "scale": "2x", + "size": "32x32" + }, + { + "filename": "icon-mac-128@1x.png", + "idiom": "mac", + "scale": "1x", + "size": "128x128" + }, + { + "filename": "icon-mac-128@2x.png", + "idiom": "mac", + "scale": "2x", + "size": "128x128" + }, + { + "filename": "icon-mac-256@1x.png", + "idiom": "mac", + "scale": "1x", + "size": "256x256" + }, + { + "filename": "icon-mac-256@2x.png", + "idiom": "mac", + "scale": "2x", + "size": "256x256" + }, + { + "filename": "icon-mac-512@1x.png", + "idiom": "mac", + "scale": "1x", + "size": "512x512" + }, + { + "filename": "icon-mac-512@2x.png", + "idiom": "mac", + "scale": "2x", + "size": "512x512" + } + ], + "info": { + "author": "xcode", + "version": 1 + } +} \ No newline at end of file diff --git a/appleApp/FTW/Assets.xcassets/AppIcon.appiconset/icon-ios-1024.png b/appleApp/FTW/Assets.xcassets/AppIcon.appiconset/icon-ios-1024.png new file mode 100644 index 0000000..91a152a Binary files /dev/null and b/appleApp/FTW/Assets.xcassets/AppIcon.appiconset/icon-ios-1024.png differ diff --git a/appleApp/FTW/Assets.xcassets/AppIcon.appiconset/icon-mac-128@1x.png b/appleApp/FTW/Assets.xcassets/AppIcon.appiconset/icon-mac-128@1x.png new file mode 100644 index 0000000..e548c7e Binary files /dev/null and b/appleApp/FTW/Assets.xcassets/AppIcon.appiconset/icon-mac-128@1x.png differ diff --git a/appleApp/FTW/Assets.xcassets/AppIcon.appiconset/icon-mac-128@2x.png b/appleApp/FTW/Assets.xcassets/AppIcon.appiconset/icon-mac-128@2x.png new file mode 100644 index 0000000..5bd332f Binary files /dev/null and b/appleApp/FTW/Assets.xcassets/AppIcon.appiconset/icon-mac-128@2x.png differ diff --git a/appleApp/FTW/Assets.xcassets/AppIcon.appiconset/icon-mac-16@1x.png b/appleApp/FTW/Assets.xcassets/AppIcon.appiconset/icon-mac-16@1x.png new file mode 100644 index 0000000..dd8790b Binary files /dev/null and b/appleApp/FTW/Assets.xcassets/AppIcon.appiconset/icon-mac-16@1x.png differ diff --git a/appleApp/FTW/Assets.xcassets/AppIcon.appiconset/icon-mac-16@2x.png b/appleApp/FTW/Assets.xcassets/AppIcon.appiconset/icon-mac-16@2x.png new file mode 100644 index 0000000..e8a13a2 Binary files /dev/null and b/appleApp/FTW/Assets.xcassets/AppIcon.appiconset/icon-mac-16@2x.png differ diff --git a/appleApp/FTW/Assets.xcassets/AppIcon.appiconset/icon-mac-256@1x.png b/appleApp/FTW/Assets.xcassets/AppIcon.appiconset/icon-mac-256@1x.png new file mode 100644 index 0000000..5bd332f Binary files /dev/null and b/appleApp/FTW/Assets.xcassets/AppIcon.appiconset/icon-mac-256@1x.png differ diff --git a/appleApp/FTW/Assets.xcassets/AppIcon.appiconset/icon-mac-256@2x.png b/appleApp/FTW/Assets.xcassets/AppIcon.appiconset/icon-mac-256@2x.png new file mode 100644 index 0000000..42decea Binary files /dev/null and b/appleApp/FTW/Assets.xcassets/AppIcon.appiconset/icon-mac-256@2x.png differ diff --git a/appleApp/FTW/Assets.xcassets/AppIcon.appiconset/icon-mac-32@1x.png b/appleApp/FTW/Assets.xcassets/AppIcon.appiconset/icon-mac-32@1x.png new file mode 100644 index 0000000..e8a13a2 Binary files /dev/null and b/appleApp/FTW/Assets.xcassets/AppIcon.appiconset/icon-mac-32@1x.png differ diff --git a/appleApp/FTW/Assets.xcassets/AppIcon.appiconset/icon-mac-32@2x.png b/appleApp/FTW/Assets.xcassets/AppIcon.appiconset/icon-mac-32@2x.png new file mode 100644 index 0000000..652376e Binary files /dev/null and b/appleApp/FTW/Assets.xcassets/AppIcon.appiconset/icon-mac-32@2x.png differ diff --git a/appleApp/FTW/Assets.xcassets/AppIcon.appiconset/icon-mac-512@1x.png b/appleApp/FTW/Assets.xcassets/AppIcon.appiconset/icon-mac-512@1x.png new file mode 100644 index 0000000..42decea Binary files /dev/null and b/appleApp/FTW/Assets.xcassets/AppIcon.appiconset/icon-mac-512@1x.png differ diff --git a/appleApp/FTW/Assets.xcassets/AppIcon.appiconset/icon-mac-512@2x.png b/appleApp/FTW/Assets.xcassets/AppIcon.appiconset/icon-mac-512@2x.png new file mode 100644 index 0000000..0b759c2 Binary files /dev/null and b/appleApp/FTW/Assets.xcassets/AppIcon.appiconset/icon-mac-512@2x.png differ diff --git a/appleApp/FTW/Assets.xcassets/Contents.json b/appleApp/FTW/Assets.xcassets/Contents.json new file mode 100644 index 0000000..c47b5f2 --- /dev/null +++ b/appleApp/FTW/Assets.xcassets/Contents.json @@ -0,0 +1,6 @@ +{ + "info": { + "author": "xcode", + "version": 1 + } +} \ No newline at end of file diff --git a/appleApp/FTW/FTWApp.swift b/appleApp/FTW/FTWApp.swift new file mode 100644 index 0000000..b03be0a --- /dev/null +++ b/appleApp/FTW/FTWApp.swift @@ -0,0 +1,77 @@ +import FTWKit +import SwiftUI + +@main +struct FTWApp: App { + @State private var app: AppModel + @Environment(\.scenePhase) private var scenePhase + private let network: NetworkWatch + static let mainWindow = "main" + + init() { + let store = KeychainStore() + let files = SealedFiles(directory: SealedFiles.defaultDirectory(), store: store) + let model = AppModel(store: store, files: files, passkeys: Passkeys(), build: AppInfo.build, ua: AppInfo.userAgent) + // A paired phone paints its cached home before anything else runs. + model.launch() + #if DEBUG + // For screenshots in CI: `-FTWDemo YES` opens the simulated home. + if UserDefaults.standard.bool(forKey: "FTWDemo") { model.startDemo() } + #endif + _app = State(initialValue: model) + network = NetworkWatch { [weak model] in model?.networkOnline() } + } + + var body: some Scene { + WindowGroup(id: Self.mainWindow) { + RootView(app: app) + .onOpenURL { app.offer($0.absoluteString) } + .onChange(of: scenePhase, initial: true) { _, phase in + app.setVisible(AppInfo.isVisible(phase)) + } + } + #if os(macOS) + .defaultSize(width: 520, height: 820) + #endif + + #if os(macOS) + MenuBarExtra { + GlanceView(app: app) + } label: { + Image(systemName: "bolt.fill") + } + .menuBarExtraStyle(.window) + #endif + } +} + +enum AppInfo { + static var version: String { + Bundle.main.object(forInfoDictionaryKey: "CFBundleShortVersionString") as? String ?? "0" + } + + static var buildNumber: String { + Bundle.main.object(forInfoDictionaryKey: "CFBundleVersion") as? String ?? "0" + } + + /// What the box records about this app in its hello. + static var build: String { "\(version)+\(buildNumber)" } + + static var userAgent: String { + #if os(iOS) + return "FTW iOS \(version)" + #else + return "FTW macOS \(version)" + #endif + } + + /// A phone in the background is not looked at. A Mac window behind + /// another app still is. + static func isVisible(_ phase: ScenePhase) -> Bool { + #if os(iOS) + return phase == .active + #else + return phase != .background + #endif + } +} diff --git a/appleApp/FTW/Platform/KeychainStore.swift b/appleApp/FTW/Platform/KeychainStore.swift new file mode 100644 index 0000000..a4b98a4 --- /dev/null +++ b/appleApp/FTW/Platform/KeychainStore.swift @@ -0,0 +1,67 @@ +import Foundation +import FTWKit +import Security + +/// The secure store on Apple platforms: generic passwords, readable after +/// first unlock, never synced and never migrated to another device. Cold +/// start reads it without Face ID, which is what lets the cache paint first. +@MainActor +final class KeychainStore: SecureStore { + private let service = "energy.ftw.app" + /// The data protection keychain on the Mac, the one iOS always uses. A + /// build without a signing team cannot reach it, and falls back to the + /// login keychain rather than failing to remember anything. + private var dataProtection = true + + func get(_ key: String) -> Bytes? { + var query = base(key) + query[kSecReturnData as String] = true + query[kSecMatchLimit as String] = kSecMatchLimitOne + var result: AnyObject? + let status = run { SecItemCopyMatching(query.merging(self.flavour) { $1 } as CFDictionary, &result) } + guard status == errSecSuccess, let data = result as? Data else { return nil } + return data.byteArray + } + + func put(_ key: String, _ value: Bytes) { + let query = base(key) + let update: [String: Any] = [kSecValueData as String: Data(value)] + let status = run { SecItemUpdate(query.merging(self.flavour) { $1 } as CFDictionary, update as CFDictionary) } + if status == errSecItemNotFound { + var item = query + item[kSecValueData as String] = Data(value) + item[kSecAttrAccessible as String] = kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly + _ = run { SecItemAdd(item.merging(self.flavour) { $1 } as CFDictionary, nil) } + } + } + + func remove(_ key: String) { + let query = base(key) + _ = run { SecItemDelete(query.merging(self.flavour) { $1 } as CFDictionary) } + } + + private func base(_ key: String) -> [String: Any] { + [ + kSecClass as String: kSecClassGenericPassword, + kSecAttrService as String: service, + kSecAttrAccount as String: key, + ] + } + + private var flavour: [String: Any] { + #if os(macOS) + return dataProtection ? [kSecUseDataProtectionKeychain as String: true] : [:] + #else + return [:] + #endif + } + + private func run(_ call: () -> OSStatus) -> OSStatus { + let status = call() + if status == errSecMissingEntitlement, dataProtection { + dataProtection = false + return call() + } + return status + } +} diff --git a/appleApp/FTW/Platform/NetworkWatch.swift b/appleApp/FTW/Platform/NetworkWatch.swift new file mode 100644 index 0000000..471a600 --- /dev/null +++ b/appleApp/FTW/Platform/NetworkWatch.swift @@ -0,0 +1,29 @@ +import Foundation +import Network + +/// Tells the app when a network path comes back, so a stream replaces its +/// dead socket at once instead of waiting out a timeout. Lives as long as +/// the app does. +@MainActor +final class NetworkWatch { + private let monitor = NWPathMonitor() + private let onOnline: @MainActor () -> Void + private var wasOnline = true + + init(onOnline: @escaping @MainActor () -> Void) { + self.onOnline = onOnline + // Sendable on purpose: the monitor calls this on its own queue, and a + // closure left to infer main-actor isolation would trap there. + monitor.pathUpdateHandler = { @Sendable [weak self] path in + guard let self else { return } + let online = path.status == .satisfied + Task { @MainActor in self.update(online) } + } + monitor.start(queue: DispatchQueue(label: "energy.ftw.network")) + } + + private func update(_ online: Bool) { + if online, !wasOnline { onOnline() } + wasOnline = online + } +} diff --git a/appleApp/FTW/Platform/Passkeys.swift b/appleApp/FTW/Platform/Passkeys.swift new file mode 100644 index 0000000..6bfc505 --- /dev/null +++ b/appleApp/FTW/Platform/Passkeys.swift @@ -0,0 +1,134 @@ +import AuthenticationServices +import CryptoKit +import FTWKit +import Foundation + +/// The passkey ceremony, with PRF, against the web app's relying party. The +/// same passkey and the same salt give the same PRF output on the web and +/// here, which is what lets one passkey open a home in both apps. +@MainActor +final class Passkeys: PasskeyAuthenticator { + private var pending: Ceremony? + + var isAvailable: Bool { true } + + func register(label: String, userHandle: Bytes, excludeCredentialIDs: [String]) async throws -> PasskeyOutcome { + let provider = ASAuthorizationPlatformPublicKeyCredentialProvider(relyingPartyIdentifier: Origin.rpID) + let request = provider.createCredentialRegistrationRequest(challenge: Data(randomBytes(32)), name: label, userID: Data(userHandle)) + request.userVerificationPreference = .required + request.excludedCredentials = descriptors(excludeCredentialIDs) + request.prf = .inputValues(.init(saltInput1: Data(PRF.vaultSalt))) + let answer = try await perform(request) + return PasskeyOutcome(credentialID: Base64url.encode(answer.credentialID.byteArray), prfOutput: answer.prf?.byteArray, prfEnabled: answer.prfSupported) + } + + func assert(credentialIDs: [String]) async throws -> PasskeyOutcome { + let provider = ASAuthorizationPlatformPublicKeyCredentialProvider(relyingPartyIdentifier: Origin.rpID) + let request = provider.createCredentialAssertionRequest(challenge: Data(randomBytes(32))) + request.userVerificationPreference = .required + request.allowedCredentials = descriptors(credentialIDs) + request.prf = .inputValues(.init(saltInput1: Data(PRF.vaultSalt))) + let answer = try await perform(request) + return PasskeyOutcome(credentialID: Base64url.encode(answer.credentialID.byteArray), prfOutput: answer.prf?.byteArray) + } + + private func descriptors(_ ids: [String]) -> [ASAuthorizationPlatformPublicKeyCredentialDescriptor] { + ids.compactMap { try? Base64url.decode($0) }.map { ASAuthorizationPlatformPublicKeyCredentialDescriptor(credentialID: Data($0)) } + } + + private func perform(_ request: ASAuthorizationRequest) async throws -> CeremonyAnswer { + // One sheet at a time; a second ask while one is up is the same ask. + pending?.cancel() + let ceremony = Ceremony(request) + pending = ceremony + defer { if pending === ceremony { pending = nil } } + return try await ceremony.run() + } +} + +/// The platform answered with a credential of a kind this app never asked for. +private struct UnexpectedCredential: Error {} + +/// What a ceremony produced, copied out of the platform's credential on the +/// main actor so only plain values cross back to the caller. +private struct CeremonyAnswer: Sendable { + let credentialID: Data + let prf: Data? + /// At registration: the platform will evaluate PRF on an assertion even + /// when it gave no output yet. + let prfSupported: Bool + + init(_ credential: ASAuthorizationCredential) throws { + if let created = credential as? ASAuthorizationPlatformPublicKeyCredentialRegistration { + credentialID = created.credentialID + prf = created.prf?.first.map(Self.data) + prfSupported = created.prf?.isSupported ?? false + } else if let asserted = credential as? ASAuthorizationPlatformPublicKeyCredentialAssertion { + credentialID = asserted.credentialID + prf = asserted.prf.map { Self.data($0.first) } + prfSupported = asserted.prf != nil + } else { + throw UnexpectedCredential() + } + } + + private static func data(_ key: SymmetricKey) -> Data { + key.withUnsafeBytes { Data($0) } + } +} + +/// One authorization controller and the continuation it answers. +@MainActor +private final class Ceremony: NSObject { + private let controller: ASAuthorizationController + private var continuation: CheckedContinuation? + + init(_ request: ASAuthorizationRequest) { + controller = ASAuthorizationController(authorizationRequests: [request]) + super.init() + controller.delegate = self + controller.presentationContextProvider = self + } + + func run() async throws -> CeremonyAnswer { + try await withCheckedThrowingContinuation { continuation in + self.continuation = continuation + controller.performRequests() + } + } + + func cancel() { + controller.cancel() + } + + fileprivate func finish(_ result: Result) { + continuation?.resume(with: result) + continuation = nil + } +} + +extension Ceremony: ASAuthorizationControllerDelegate { + func authorizationController(controller: ASAuthorizationController, didCompleteWithAuthorization authorization: ASAuthorization) { + finish(Result { try CeremonyAnswer(authorization.credential) }) + } + + func authorizationController(controller: ASAuthorizationController, didCompleteWithError error: Error) { + // A dismissed sheet is an answer, not a fault, and gets its own type. + if let e = error as? ASAuthorizationError, e.code == .canceled { + finish(.failure(PasskeyCancelled())) + } else { + finish(.failure(error)) + } + } +} + +extension Ceremony: ASAuthorizationControllerPresentationContextProviding { + func presentationAnchor(for controller: ASAuthorizationController) -> ASPresentationAnchor { + #if os(iOS) + let windows = UIApplication.shared.connectedScenes.compactMap { $0 as? UIWindowScene }.flatMap(\.windows) + return windows.first(where: \.isKeyWindow) ?? windows.first ?? ASPresentationAnchor() + #else + return NSApplication.shared.keyWindow ?? NSApplication.shared.windows.first ?? ASPresentationAnchor() + #endif + } +} diff --git a/appleApp/FTW/Platform/QRScanner.swift b/appleApp/FTW/Platform/QRScanner.swift new file mode 100644 index 0000000..5b09354 --- /dev/null +++ b/appleApp/FTW/Platform/QRScanner.swift @@ -0,0 +1,211 @@ +import AVFoundation +import CoreImage +import SwiftUI + +/// Reads a pairing QR through the camera. The first code that looks like an +/// FTW link is handed over once; the camera stops at that moment. +struct QRScanner: View { + var onCode: (String) -> Void + @State private var allowed: Bool? + + var body: some View { + Group { + switch allowed { + case .some(true): + CameraView(onCode: onCode) + case .some(false): + Text("FTW needs the camera to read the pairing code. Allow it in Settings, then try again.") + .font(.footnote) + .foregroundStyle(Theme.fgDim) + .padding() + case .none: + Color.black + } + } + .task { + switch AVCaptureDevice.authorizationStatus(for: .video) { + case .authorized: allowed = true + case .notDetermined: allowed = await AVCaptureDevice.requestAccess(for: .video) + default: allowed = false + } + } + } +} + +/// QR codes in a still picture, for a Mac reading a screenshot of the code. +enum QRImage { + static func looksLikePairing(_ text: String) -> Bool { + text.contains("ftw.energy") || text.contains("/p#") + } + + static func codes(in image: CIImage) -> [String] { + let detector = CIDetector(ofType: CIDetectorTypeQRCode, context: nil, options: [CIDetectorAccuracy: CIDetectorAccuracyHigh]) + return (detector?.features(in: image) ?? []).compactMap { ($0 as? CIQRCodeFeature)?.messageString } + } + + static func pairingCode(at url: URL) -> String? { + guard let image = CIImage(contentsOf: url) else { return nil } + return codes(in: image).first(where: QRImage.looksLikePairing) + } +} + +/// The capture session, started and stopped off the main thread as +/// AVFoundation asks. Held in a box so the closures that move it are honest +/// about crossing threads. +private final class CaptureBox: @unchecked Sendable { + let session = AVCaptureSession() + private let queue = DispatchQueue(label: "energy.ftw.camera") + + func start() { queue.async { self.session.startRunning() } } + func stop() { queue.async { self.session.stopRunning() } } +} + +#if os(iOS) +private struct CameraView: UIViewRepresentable { + var onCode: (String) -> Void + + func makeUIView(context: Context) -> PreviewView { + let view = PreviewView() + context.coordinator.attach(view) + return view + } + + func updateUIView(_ view: PreviewView, context: Context) { + context.coordinator.onCode = onCode + } + + static func dismantleUIView(_ view: PreviewView, coordinator: Coordinator) { + coordinator.capture.stop() + } + + func makeCoordinator() -> Coordinator { Coordinator(onCode: onCode) } + + final class PreviewView: UIView { + override class var layerClass: AnyClass { AVCaptureVideoPreviewLayer.self } + var preview: AVCaptureVideoPreviewLayer { layer as! AVCaptureVideoPreviewLayer } + } + + @MainActor + final class Coordinator: NSObject { + let capture = CaptureBox() + var onCode: (String) -> Void + private var done = false + + init(onCode: @escaping (String) -> Void) { + self.onCode = onCode + } + + func attach(_ view: PreviewView) { + let session = capture.session + guard let device = AVCaptureDevice.default(for: .video), + let input = try? AVCaptureDeviceInput(device: device), + session.canAddInput(input) else { return } + session.addInput(input) + let output = AVCaptureMetadataOutput() + guard session.canAddOutput(output) else { return } + session.addOutput(output) + output.setMetadataObjectsDelegate(self, queue: .main) + output.metadataObjectTypes = [.qr] + view.preview.session = session + view.preview.videoGravity = .resizeAspectFill + capture.start() + } + + func found(_ text: String) { + guard !done, QRImage.looksLikePairing(text) else { return } + done = true + capture.stop() + onCode(text) + } + } +} + +extension CameraView.Coordinator: @preconcurrency AVCaptureMetadataOutputObjectsDelegate { + func metadataOutput(_ output: AVCaptureMetadataOutput, didOutput metadataObjects: [AVMetadataObject], from connection: AVCaptureConnection) { + for object in metadataObjects { + if let text = (object as? AVMetadataMachineReadableCodeObject)?.stringValue { found(text) } + } + } +} +#else +private struct CameraView: NSViewRepresentable { + var onCode: (String) -> Void + + func makeNSView(context: Context) -> NSView { + let view = NSView() + view.wantsLayer = true + context.coordinator.attach(view) + return view + } + + func updateNSView(_ view: NSView, context: Context) { + context.coordinator.onCode = onCode + } + + static func dismantleNSView(_ view: NSView, coordinator: Coordinator) { + coordinator.capture.stop() + } + + func makeCoordinator() -> Coordinator { Coordinator(onCode: onCode) } + + /// The Mac has no metadata output, so frames go through Core Image. + @MainActor + final class Coordinator: NSObject { + let capture = CaptureBox() + var onCode: (String) -> Void + private var done = false + private let frames = DispatchQueue(label: "energy.ftw.frames") + private var reader: FrameReader? + + init(onCode: @escaping (String) -> Void) { + self.onCode = onCode + } + + func attach(_ view: NSView) { + let session = capture.session + guard let device = AVCaptureDevice.default(for: .video), + let input = try? AVCaptureDeviceInput(device: device), + session.canAddInput(input) else { return } + session.addInput(input) + let output = AVCaptureVideoDataOutput() + output.alwaysDiscardsLateVideoFrames = true + guard session.canAddOutput(output) else { return } + session.addOutput(output) + let reader = FrameReader(owner: self) + self.reader = reader + output.setSampleBufferDelegate(reader, queue: frames) + let preview = AVCaptureVideoPreviewLayer(session: session) + preview.videoGravity = .resizeAspectFill + preview.frame = view.bounds + preview.autoresizingMask = [.layerWidthSizable, .layerHeightSizable] + view.layer?.addSublayer(preview) + capture.start() + } + + func found(_ text: String) { + guard !done, QRImage.looksLikePairing(text) else { return } + done = true + capture.stop() + onCode(text) + } + } + + /// Decodes on the frame queue and hands text to the main actor. + final class FrameReader: NSObject, AVCaptureVideoDataOutputSampleBufferDelegate, @unchecked Sendable { + private weak var owner: Coordinator? + + init(owner: Coordinator) { + self.owner = owner + } + + func captureOutput(_ output: AVCaptureOutput, didOutput sampleBuffer: CMSampleBuffer, from connection: AVCaptureConnection) { + guard let pixels = CMSampleBufferGetImageBuffer(sampleBuffer) else { return } + guard let text = QRImage.codes(in: CIImage(cvPixelBuffer: pixels)).first(where: QRImage.looksLikePairing) else { return } + let target = owner + DispatchQueue.main.async { + MainActor.assumeIsolated { target?.found(text) } + } + } + } +} +#endif diff --git a/appleApp/FTW/UI/BoxView.swift b/appleApp/FTW/UI/BoxView.swift new file mode 100644 index 0000000..9bfaa04 --- /dev/null +++ b/appleApp/FTW/UI/BoxView.swift @@ -0,0 +1,353 @@ +import CoreImage +import CoreImage.CIFilterBuiltins +import FTWKit +import SwiftUI + +/// The box this phone is paired to: what it is, who else it trusts, what it +/// tells phones, the spare key, and leaving. +struct BoxView: View { + let app: AppModel + let home: HomeModels + @State private var leaving = false + + private var site: SiteModel { home.site } + private var stored: StoredSite? { site.siteId.flatMap { app.sites.get($0) } } + + var body: some View { + Group { + Title("Your FTW Box") + if let stored { + Text(SiteList.fingerprint(stored.boxStaticKey.byteArray)) + .font(Theme.number(17)) + .foregroundStyle(Theme.fgDim) + } + Card { + Row("App version", "\(AppInfo.version) (\(AppInfo.buildNumber))", number: true) + if let build = site.session.box?.build { Row("Software", build, number: true) } + if let zone = Self.timeZone(site.session.box?.tz) { Row("Time zone", zone) } + if let stored { Row("Paired", Clock.day(stored.addedAtMs)) } + if let id = app.vault.deviceIDOnBox { Row("This phone", id, number: true) } + } + + AccessSection(site: site, access: home.access) + NotificationsSection(site: site, notify: home.notify) + RestartSection(site: site, restart: home.restart) + if let copy = app.sealedCopy { + SealedCopySection(copy: copy) + } + signOut + } + .onAppear { + home.access.activate() + home.notify.activate() + app.sealedCopy?.reload() + } + .onDisappear { + home.access.deactivate() + home.notify.deactivate() + } + } + + @ViewBuilder private var signOut: some View { + Divider().overlay(Theme.line) + if leaving { + Text("Sign out on this phone?").font(.title3.weight(.semibold)) + Text("This phone stops showing your home and forgets its key. Nothing is removed from your box. It keeps running and keeps every reading.") + if app.sealedCopy?.kept == true { + Text("The sealed copy stays, so you can open this home again with your passkey. To remove it instead, turn off the sealed copy above before you sign out.") + } else { + Text("To come back, open the box's local dashboard and use Settings → FTW app → Show pairing code.") + } + Text("Signing out here does not remove this phone from your box. If you are handing the phone on, remove it there too: Settings, then FTW app\(app.vault.deviceIDOnBox.map { ", looking for \($0)" } ?? "").") + Button("Sign out") { app.leave() }.buttonStyle(.danger) + Button("Cancel") { leaving = false }.buttonStyle(.quiet) + } else { + Text("Sign out").font(.title3.weight(.semibold)) + Text("Clears this home from this phone. Your box and its readings are not touched.") + Button("Sign out") { leaving = true }.buttonStyle(.outline) + } + } + + /// A box that says "local" means the zone it runs in, which is almost + /// always the phone's own. + static func timeZone(_ zone: String?) -> String? { + guard let named = zone?.trimmingCharacters(in: .whitespaces), !named.isEmpty else { return nil } + return named.lowercased() == "local" ? TimeZone.current.identifier : named + } +} + +private struct Row: View { + let label: String + let value: String + var number = false + + init(_ label: String, _ value: String, number: Bool = false) { + self.label = label + self.value = value + self.number = number + } + + var body: some View { + HStack(alignment: .firstTextBaseline) { + Text(label).foregroundStyle(Theme.fgDim) + Spacer() + Text(value) + .font(number ? Theme.number(14, weight: .regular) : .body) + .multilineTextAlignment(.trailing) + .textSelection(.enabled) + } + .font(.callout) + } +} + +// MARK: Who can see this home + +private struct AccessSection: View { + let site: SiteModel + let access: AccessModel + @State private var confirming: String? + + var body: some View { + Divider().overlay(Theme.line) + Text("Who can see this home").font(.title3.weight(.semibold)) + if !site.heardFromBox { + Text("Reaching your box…") + } else if !site.canConfigure { + Text("You have view-only access. You can see this home's readings; changing anything, and who else can see it, belongs to its owner.") + } else if !site.hasPassthrough { + Text("Sharing needs newer software on your box.") + } else { + members + invite + if let error = access.error { Problem(error) } + } + } + + @ViewBuilder private var members: some View { + if access.members.isEmpty { + Hint(access.loading ? "Reading your box…" : access.loaded ? "No phones are paired with this box." : "The list has not come through yet. Still asking.") + } + ForEach(access.members) { member in + HStack(alignment: .center) { + VStack(alignment: .leading, spacing: 2) { + Text(member.id).font(Theme.number(15)) + Text("\(AccessModel.roleLabel(member.role)) · \(access.seen(member.lastSeenMs))\(member.isThisPhone ? " · this phone" : "")") + .font(.caption) + .foregroundStyle(Theme.fgDim) + } + Spacer() + if member.isThisPhone { + Text("use Sign out").font(.caption).foregroundStyle(Theme.fgMuted) + } else if member.role == Contract.roleOwner, access.owners <= 1 { + Text("last owner").font(.caption).foregroundStyle(Theme.fgMuted) + } else if confirming == member.id { + Button("Remove") { + Task { if await access.revoke(member.id) { confirming = nil } } + } + .buttonStyle(.danger) + .fixedSize() + .disabled(access.busy == .revoking) + Button("Cancel") { confirming = nil }.buttonStyle(.quiet) + } else { + Button("Remove") { confirming = member.id }.buttonStyle(.outline) + } + } + .padding(.vertical, 4) + } + } + + @ViewBuilder private var invite: some View { + if let invite = access.invite { + Text("Let the person joining point their camera at this. It lets them see this home and nothing else, and it works once\(invite.expiresAtMs > 0 ? ", until \(Clock.time(invite.expiresAtMs))" : "").") + // A square and never text: what is in it is everything it takes + // to become a phone this house trusts. + QRCodeView(text: invite.url) + .frame(width: 240, height: 240) + .frame(maxWidth: .infinity) + .accessibilityLabel("Pairing code for this home") + Button("Done") { access.dismissInvite() }.buttonStyle(.quiet) + } else { + Hint("A viewer sees your readings and can change nothing. Making an invitation cancels any pairing code already showing on your box.") + Button(access.busy == .inviting ? "Asking your box" : "Invite someone to view") { + Task { await access.inviteViewer() } + } + .buttonStyle(.outline) + .disabled(access.busy == .inviting) + } + } +} + +/// A QR drawn on the device, crisp at any size. +struct QRCodeView: View { + let text: String + + var body: some View { + if let image = Self.render(text) { + Image(decorative: image, scale: 1) + .interpolation(.none) + .resizable() + .scaledToFit() + .padding(12) + .background(Color.white, in: RoundedRectangle(cornerRadius: Theme.radiusSmall)) + } else { + Hint("The code didn't draw. Open this screen again to draw it.") + } + } + + static func render(_ text: String) -> CGImage? { + let filter = CIFilter.qrCodeGenerator() + filter.message = Data(text.utf8) + filter.correctionLevel = "M" + guard let output = filter.outputImage else { return nil } + return CIContext().createCGImage(output, from: output.extent) + } +} + +// MARK: Notifications + +private struct NotificationsSection: View { + let site: SiteModel + let notify: NotifyModel + + var body: some View { + Divider().overlay(Theme.line) + Text("Notifications").font(.title3.weight(.semibold)) + if !site.heardFromBox { + Text("Reaching your box…") + } else if !site.canConfigure { + Text("Notifications are turned on by this home's owner.") + } else if !site.hasPassthrough { + Text("Notifications need newer software on your box.") + } else if notify.oldBox { + Text("Your box doesn't have that yet. It may be running older software.") + } else { + // The box reaches phones through web push today. This app has no + // push channel of its own, and says so rather than offering a + // switch that could not deliver. + Text("This app does not receive notifications yet. Your box sends them to phones that turned them on in the FTW web app, and the choices below apply to every one of them.") + if notify.boxEnabled { + rules + } else { + Hint("No phone has turned notifications on for this box. Turn them on from the FTW web app on your phone.") + } + if let error = notify.error { Problem(error) } + } + } + + @ViewBuilder private var rules: some View { + ForEach(NotifyModel.ruleKinds.filter { notify.availableKinds.contains($0) }, id: \.self) { kind in + Toggle(NotifyModel.labels[kind] ?? kind, isOn: Binding( + get: { notify.rules[kind] ?? false }, + set: { on in + var next = notify.rules + next[kind] = on + Task { await notify.save(next) } + } + )) + .disabled(notify.busy != .none) + } + Hint("\(NotifyModel.labels["box.unreachable"] ?? "") is always on while notifications are on.") + if notify.busy == .saving { Hint("Saving…") } + Button(notify.busy == .testing ? "Asking your box…" : "Send a test") { + Task { await notify.sendTest() } + } + .buttonStyle(.outline) + .disabled(notify.busy != .none) + if notify.testSent { + Hint("Sent. It shows up on the phones your box can reach in a moment.") + } + if !notify.history.isEmpty { + VStack(alignment: .leading, spacing: 6) { + ForEach(notify.history) { row in + HStack { + Text(row.title).font(.callout) + Spacer() + Text(notify.when(row.atMs)).font(.caption).foregroundStyle(Theme.fgMuted) + } + } + } + } + } +} + +// MARK: Restart + +private struct RestartSection: View { + let site: SiteModel + let restart: RestartModel + + var body: some View { + if restart.canAsk { + Divider().overlay(Theme.line) + switch restart.stage { + case .confirming: + Text("Restart this box?").font(.title3.weight(.semibold)) + Text("The software restarts. Devices keep running on their own until it comes back, usually within a minute. This phone reconnects by itself.") + Button("Restart now") { Task { await restart.restart() } }.buttonStyle(.danger) + Button("Cancel") { restart.stage = .idle }.buttonStyle(.quiet) + case .restarting: + Text("Restarting").font(.title3.weight(.semibold)) + Text("Your box is coming back on its own. This usually takes a minute.") + case .idle: + Text("Restart").font(.title3.weight(.semibold)) + Text("Restarts the software on this box. Use it when a device is stuck and will not come back on its own.") + Button("Restart this box") { restart.stage = .confirming }.buttonStyle(.outline) + } + if let error = restart.error { Problem(error) } + } + } +} + +// MARK: The spare key + +private struct SealedCopySection: View { + let copy: SealedCopyModel + + var body: some View { + Divider().overlay(Theme.line) + Text("If you lose this phone").font(.title3.weight(.semibold)) + if copy.kept { + Text("Sourceful holds a sealed copy it cannot open, with an opaque id and nothing beside it. A new phone gets this home back with your passkey alone.") + Text("Which also means your passkey is enough to open this home, on any device that can pass Face ID for it.") + Button(copy.stage == .working ? "Removing the copy" : "Remove the copy") { Task { await copy.set(false) } } + .buttonStyle(.outline) + .disabled(copy.stage == .working) + } else { + Text("No sealed recovery copy is saved for this home right now. FTW normally makes one when you pair on a phone that supports passkey recovery. Sourceful can hold that copy without opening it, with an opaque id and nothing beside it, so a new phone gets this home back with your passkey.") + Text("The cost: your passkey is then enough to open this home, on any device that can pass Face ID for it. Nothing is saved unless you ask.") + Button(copy.stage == .working ? "Saving a sealed copy" : "Keep a sealed copy") { Task { await copy.set(true) } } + .buttonStyle(.outline) + .disabled(copy.stage == .working) + } + if copy.stage == .failed, let problem = copy.problem { Problem(problem) } + } +} + +// MARK: The demo's Box screen + +struct DemoBoxView: View { + let site: SiteModel + let exit: () -> Void + + var body: some View { + Title("Demo home") + Text("Simulated FTW box").font(Theme.number(15)).foregroundStyle(Theme.fgDim) + Text("This is the same app and protocol as a connected home. The readings and changes stay in this demo and reset when you leave.") + Card { + Row("App version", "\(AppInfo.version) (\(AppInfo.buildNumber))", number: true) + if let build = site.session.box?.build { Row("Software", build, number: true) } + if let zone = BoxView.timeZone(site.session.box?.tz) { Row("Time zone", zone) } + Row("Data", "Simulated") + } + Divider().overlay(Theme.line) + Text("Access").font(.title3.weight(.semibold)) + Text("On a connected home, this screen lists owner and viewer phones. An owner can invite a viewer or remove access.") + Divider().overlay(Theme.line) + Text("Notifications").font(.title3.weight(.semibold)) + Text("A connected home can tell its phones about useful events, such as a finished EV charge, a device that stops answering, or a box that went quiet.") + Divider().overlay(Theme.line) + Text("Passkey recovery").font(.title3.weight(.semibold)) + Text("A real home can keep a sealed recovery copy for its passkey. Sourceful cannot open it, and the owner can remove it here.") + Button("Exit demo and connect your box", action: exit).buttonStyle(.primary) + } +} diff --git a/appleApp/FTW/UI/EVSheet.swift b/appleApp/FTW/UI/EVSheet.swift new file mode 100644 index 0000000..b7e0c12 --- /dev/null +++ b/appleApp/FTW/UI/EVSheet.swift @@ -0,0 +1,584 @@ +import FTWKit +import SwiftUI + +/// The charger sheet. Everything on it is a fact the box served; every +/// control expresses intent with an expiry, and the box decides. After any +/// outcome the charger is read again, because the box's account is the +/// truth to repaint from. Controls are hidden from viewers as presentation; +/// the box's refusal is the actual gate. +struct EVSheet: View { + let site: SiteModel + let model: LoadpointsModel + let loadpointID: String? + @Environment(\.dismiss) private var dismiss + + struct GoalDraft: Equatable { + var loadpointID: String + var time: Date + var recurring: Bool + var days: Int + var socPct: Double + var surplusUnlockPct: Double + } + + struct BoostDraft: Equatable { + var loadpointID: String + var reservePct: Int + var durationS: Int + } + + @State private var goal: GoalDraft? + @State private var goalRevision = 0 + @State private var saving = false + @State private var saveError: String? + @State private var scheduleNote = "Changes apply as you make them." + @State private var justSavedGoal: String? + @State private var removedGoal: String? + @State private var boost: BoostDraft? + @State private var capacityDraft: [String: String] = [:] + @State private var capacityNote: [String: String] = [:] + @State private var capacityFailed: [String: Bool] = [:] + @State private var capacityBusy: String? + + var body: some View { + NavigationStack { + ScrollView { + VStack(alignment: .leading, spacing: 14) { content } + .padding(20) + .frame(maxWidth: 560, alignment: .leading) + .frame(maxWidth: .infinity) + } + .background(Theme.surface) + .navigationTitle("EV charger") + #if os(iOS) + .navigationBarTitleDisplayMode(.inline) + #endif + .toolbar { + ToolbarItem(placement: .confirmationAction) { + Button("Close") { dismiss() } + } + } + } + #if os(macOS) + .frame(minWidth: 460, minHeight: 560) + #endif + .onAppear { model.activate() } + .onDisappear { model.deactivate() } + .onChange(of: goal) { old, new in + // An edit, not the editor opening or closing. + guard let old, let new, old.loadpointID == new.loadpointID else { return } + goalRevision += 1 + saveError = nil + scheduleNote = "Applying schedule…" + } + .task(id: goalRevision) { + guard goalRevision > 0 else { return } + try? await Task.sleep(for: .milliseconds(400)) + guard !Task.isCancelled else { return } + await saveGoal() + } + } + + private var sending: Bool { model.command.isSending } + private var stale: Bool { model.stale } + + @ViewBuilder private var content: some View { + if site.session.phase == .streaming, !site.hasPassthrough { + Hint("Charging controls are not available from this box yet. Open the box's own page to manage charging.") + } else { + if site.session.phase != .streaming { + Hint("Connecting to your box. Charging status will appear here when it answers.") + } + if let error = model.error { Hint(error) } + if !model.loaded, model.error == nil { + if site.session.phase == .streaming { Hint("Reading your box…") } + } else { + if model.loaded, model.error == nil, model.points.isEmpty { + Hint("\(site.canConfigure ? "Connect your first charger on your box: open Settings → Chargers, then choose Connect a charger." : "Ask an owner to connect the first charger on the box, under Settings → Chargers.") Once connected and added there, it appears here too.") + } + let shown = model.points.filter { loadpointID == nil || $0.id == loadpointID } + ForEach(shown) { lp in + charger(lp) + if shown.count > 1 { Divider().overlay(Theme.line) } + } + if shown.isEmpty, model.loaded, model.error == nil, !model.points.isEmpty { + Hint("This charger is no longer listed. Close this view and choose a charger on the home screen.") + } + } + } + } + + // MARK: One charger + + @ViewBuilder private func charger(_ lp: Loadpoint) -> some View { + let canConfigure = site.canConfigure + Text(stale ? "Waiting for current charger status. The last reading is out of date." : EVText.status(lp, canControl: canConfigure)) + .font(.body.weight(.semibold)) + .fixedSize(horizontal: false, vertical: true) + if lp.manualSaveError { Hint(EVText.manualSaveErrorText) } + if !stale, let plan = planStatus(lp) { Hint(plan) } + if let seen = lp.charger?.updatedAtMs ?? lp.manual?.chargerUpdatedAtMs { + Hint("Charger last seen: \(EVText.clock(seen))", tone: Theme.fgMuted) + } + if let session = EVText.session(lp) { Text(session).font(.callout) } + + if lp.boostActive { + Text(EVText.boostActive(lp)) + .font(.footnote.weight(.semibold)) + .padding(.horizontal, 10) + .padding(.vertical, 5) + .background(Theme.storage.opacity(0.18), in: Capsule()) + if canConfigure { + Button("Stop boost") { Task { await model.stopBoost(lp) } } + .buttonStyle(.outline) + .disabled(sending) + outcome(.boost, lp) + } + } + + if lp.pluggedIn { battery(lp) } + if canConfigure, let capacity = lp.vehicleCapacityWh { carBattery(lp, capacityWh: capacity) } + if canConfigure, lp.pluggedIn { chargeNow(lp) } + goalSection(lp) + if canConfigure, lp.pluggedIn { boostSection(lp) } + if let stopped = EVText.boostStopped(lp) { Hint(stopped) } + windows(lp) + } + + private func planStatus(_ lp: Loadpoint) -> String? { + if (lp.planPending || model.planPending), justSavedGoal == lp.id { return "Goal saved. Updating the plan…" } + var merged = lp + merged.planPending = lp.planPending || model.planPending + merged.planOutdated = lp.planOutdated || model.planOutdated + return EVText.plan(merged, nowMs: site.nowMs, canControl: site.canConfigure) + } + + @ViewBuilder private func outcome(_ control: LoadpointsModel.Control, _ lp: Loadpoint) -> some View { + if let text = model.outcome(for: control, lp) { Hint(text) } + } + + private func isSending(_ control: LoadpointsModel.Control, _ lp: Loadpoint) -> Bool { + if case .sending(let c) = model.command, c == control, model.commandLoadpointID == lp.id { return true } + return false + } + + // MARK: The car's battery now + + @ViewBuilder private func battery(_ lp: Loadpoint) -> some View { + let level = model.socShown(lp) + let unconfirmed = lp.socSource == "assumed" && model.socDraft[lp.id] == nil && model.acceptedSoc[lp.id] == nil + ControlCard { + HStack { + Text("Battery now").font(.subheadline.weight(.semibold)) + Spacer() + Text(unconfirmed ? "Not confirmed" : "\(Int(level)) %").font(Theme.number(15)) + } + if site.canConfigure { + Slider(value: Binding( + get: { model.socShown(lp) }, + set: { model.socDraft[lp.id] = $0.rounded() } + ), in: 0...100, step: 1) { editing in + guard !editing else { return } + let chosen = model.socShown(lp) + Task { + await model.setSoc(lp, pct: chosen) + if model.socDraft[lp.id] == chosen { model.socDraft[lp.id] = nil } + } + } + .disabled(sending) + .accessibilityLabel("Car's current charge, percent") + } + if isSending(.soc, lp) { + Hint("Sending charge level: \(Int(level)) %…") + } else if let text = model.outcome(for: .soc, lp) { + Hint(text) + } else if site.canConfigure { + Hint(EVText.socSource(lp)) + } else { + Hint(lp.socSource == "assumed" ? "Battery level needs confirmation by someone who can control this charger." : lp.socSource == "vehicle" ? "Reported by the car." : "Estimated from energy delivered.") + } + } + } + + // MARK: The car's battery size + + private func carBattery(_ lp: Loadpoint, capacityWh: Double) -> some View { + DisclosureGroup("Car battery · \(EnergyFormat.short(capacityWh / 1000)) kWh") { + VStack(alignment: .leading, spacing: 8) { + Hint(lp.capacitySource == "default" ? "FTW is using a default size. Check it against your car." : "Used for estimates. Check this size if you use another car.") + HStack { + Text("Usable battery size (kWh)").font(.callout) + Spacer() + TextField("kWh", text: Binding( + get: { capacityDraft[lp.id] ?? EnergyFormat.short(capacityWh / 1000) }, + set: { capacityDraft[lp.id] = $0 } + )) + .multilineTextAlignment(.trailing) + .frame(width: 90) + .textFieldStyle(.roundedBorder) + #if os(iOS) + .keyboardType(.decimalPad) + #endif + .accessibilityLabel("Usable battery size, kWh") + } + Button(capacityFailed[lp.id] == true ? "Try battery size again" : "Save battery size") { + Task { await setCapacity(lp) } + } + .buttonStyle(.outline) + .disabled(capacityBusy == lp.id) + Hint("Find the usable size in your car's specifications.", tone: Theme.fgMuted) + if let note = capacityNote[lp.id] { + Hint(note, tone: capacityFailed[lp.id] == true ? Theme.importing : Theme.fgDim) + } + } + .padding(.top, 6) + } + .font(.callout) + } + + private func setCapacity(_ lp: Loadpoint) async { + guard capacityBusy == nil else { return } + let typed = (capacityDraft[lp.id] ?? "").replacingOccurrences(of: ",", with: ".") + guard let kwh = Double(typed), (1...300).contains(kwh) else { + capacityNote[lp.id] = "Enter the usable battery size from 1 to 300 kWh." + capacityFailed[lp.id] = true + return + } + capacityBusy = lp.id + capacityFailed[lp.id] = false + capacityNote[lp.id] = "Sending battery size…" + do { + capacityNote[lp.id] = try await model.setCapacity(lp, kwh: kwh) + capacityDraft[lp.id] = nil + } catch { + capacityFailed[lp.id] = true + capacityNote[lp.id] = (error as? BoxAPIError)?.help ?? "Battery size is not confirmed. Check the current value before trying again." + } + capacityBusy = nil + } + + // MARK: Charge now + + @ViewBuilder private func chargeNow(_ lp: Loadpoint) -> some View { + let range = EVText.current(lp) + let chosen = model.ampsShown(lp) + let paused = EVText.isPaused(lp) + let holding = lp.manualActive && !lp.manualRestoreUnconfirmed && !paused + ControlCard { + if holding { + HStack { + Text("Charge now is active").font(.subheadline.weight(.semibold)) + Spacer() + Text(EVText.readout(lp, amps: chosen)).font(Theme.number(14)) + } + if range.maxA > range.minA { + Slider(value: Binding( + get: { Double(model.ampsShown(lp)) }, + set: { model.ampsDraft[lp.id] = Int($0.rounded()) } + ), in: Double(range.minA)...Double(range.maxA), step: 1) { editing in + guard !editing else { return } + let amps = model.ampsShown(lp) + Task { + await model.chargeNow(lp, amps: amps) + if model.ampsDraft[lp.id] == amps { model.ampsDraft[lp.id] = nil } + } + } + .disabled(sending) + .accessibilityLabel("Charging current") + } + } + if lp.manualActive || lp.manualRestoreUnconfirmed { + Button(paused || lp.manualRestoreUnconfirmed ? "Resume plan" : "Return to plan") { + Task { await model.stopCharging(lp) } + } + .buttonStyle(.outline) + .disabled(sending) + } + if !lp.manualActive || paused || lp.manualRestoreUnconfirmed { + Button(isSending(.hold, lp) ? "Sending charge request…" : "Charge now") { + Task { await model.chargeNow(lp, amps: chosen) } + } + .buttonStyle(.primary) + .disabled(sending) + } + if !paused { + Button("Pause charging") { Task { await model.pauseCharging(lp) } } + .buttonStyle(.quiet) + .disabled(sending) + } + if lp.manualRestoreUnconfirmed { + Hint("Choose Charge now to request charging, Resume plan to use your goal, or Pause charging to request a stop.") + } else if paused { + Hint("The goal and solar rule wait until you resume the plan. Charge now starts immediately.") + } else if lp.manualActive { + Hint("Changes apply when you release the slider. Return to plan restores your schedule and solar settings.") + } else { + Hint("Starts at up to \(EVText.readout(lp, amps: chosen)). Ignores the goal and solar rule until you return to the plan or unplug.") + } + outcome(.hold, lp) + } + } + + // MARK: Your goal + + @ViewBuilder private func goalSection(_ lp: Loadpoint) -> some View { + ControlCard { + Text("Your goal").font(.headline) + if lp.manualActive || lp.manualRestoreUnconfirmed { + Hint(EVText.isPaused(lp) || lp.manualRestoreUnconfirmed ? "Resume the plan to use this goal. Edits apply then." : "Charge now overrides this goal. Edits apply when you return to the plan.") + } + if let draft = goal, draft.loadpointID == lp.id { + goalEditor(lp) + } else { + if removedGoal == lp.id { + Hint(model.error != nil ? "Goal removed. Current charging status is unavailable." : "Goal removed.") + } + if let sentence = EVText.schedule(lp) { + HStack { + Text(sentence).font(.callout) + Spacer() + if site.canConfigure { + Button("Change goal") { beginEdit(lp) }.buttonStyle(.quiet) + } + } + } else if model.loaded, site.canConfigure { + Button("Set a ready time") { beginEdit(lp) }.buttonStyle(.outline) + } + if let saveError { Hint(saveError, tone: Theme.importing) } + } + surplus(lp) + } + } + + @ViewBuilder private func goalEditor(_ lp: Loadpoint) -> some View { + if let binding = Binding($goal) { + DatePicker("Ready by", selection: binding.time, displayedComponents: .hourAndMinute) + Toggle("Repeat on chosen days", isOn: binding.recurring) + if binding.wrappedValue.recurring { + HStack(spacing: 6) { + ForEach(Array(EVText.dayLabels.enumerated()), id: \.offset) { bit, day in + let on = binding.wrappedValue.days & (1 << bit) != 0 + Button(day) { binding.wrappedValue.days ^= 1 << bit } + .font(.caption.weight(.semibold)) + .padding(.vertical, 5) + .padding(.horizontal, 7) + .foregroundStyle(on ? Theme.onAccent : Theme.fgDim) + .background(on ? Theme.accent : Theme.surfaceSunken, in: Capsule()) + .buttonStyle(.plain) + .accessibilityAddTraits(on ? .isSelected : []) + } + } + } + HStack { + Text("Charge to") + Slider(value: binding.socPct, in: 10...100, step: 5) + .accessibilityLabel("Target charge, percent") + Text("\(Int(binding.wrappedValue.socPct)) %").font(Theme.number(14)).frame(width: 52, alignment: .trailing) + } + DisclosureGroup("Solar timing") { + Toggle("Also use spare solar before the planned hours", isOn: Binding( + get: { binding.wrappedValue.surplusUnlockPct > 0 }, + set: { binding.wrappedValue.surplusUnlockPct = $0 ? 50 : 0 } + )) + if binding.wrappedValue.surplusUnlockPct > 0 { + Stepper("Keep home battery above \(Int(binding.wrappedValue.surplusUnlockPct)) %", value: binding.surplusUnlockPct, in: 1...100, step: 5) + } + } + .font(.callout) + if lp.schedule == nil { + Button("Use \(Int(binding.wrappedValue.socPct)) % by \(binding.wrappedValue.time.formatted(date: .omitted, time: .shortened))") { + Task { await saveGoal() } + } + .buttonStyle(.primary) + .disabled(saving) + } + HStack { + Button("Close goal settings") { goal = nil }.buttonStyle(.quiet).disabled(saving) + Spacer() + if lp.schedule != nil { + Button("Remove") { Task { await removeGoal(lp) } }.buttonStyle(.quiet).disabled(saving) + } + } + Hint(saveError ?? (scheduleNote == "Schedule saved." && model.error != nil ? "Schedule saved. Current charging status is unavailable." : scheduleNote), tone: saveError != nil ? Theme.importing : Theme.fgDim) + if saveError != nil { + Button("Try again") { Task { await saveGoal() } }.buttonStyle(.quiet).disabled(saving) + } + } + } + + private func beginEdit(_ lp: Loadpoint) { + if removedGoal == lp.id { removedGoal = nil } + saveError = nil + scheduleNote = lp.schedule != nil ? "Changes apply as you make them." : "No goal set yet. Choose this goal, or change the level or time." + let clock = lp.schedule.map { EVText.localTime(minuteUTC: $0.timeOfDayMinUTC) } ?? (hour: 7, minute: 0) + let time = Calendar.current.date(bySettingHour: clock.hour, minute: clock.minute, second: 0, of: Date()) ?? Date() + let wireDays = lp.schedule?.days ?? 0 + let target = lp.targetSocPct.flatMap { $0 >= 10 ? $0 : nil } ?? 80 + goal = GoalDraft( + loadpointID: lp.id, + time: time, + recurring: lp.schedule?.recurring ?? false, + days: wireDays == 0 ? 0x7F : wireDays & 0x7F, + socPct: (lp.schedule?.socPct ?? target).rounded(), + surplusUnlockPct: lp.schedule?.surplusUnlockPct ?? 0 + ) + } + + private func saveGoal() async { + guard let draft = goal, !saving, let lp = model.points.first(where: { $0.id == draft.loadpointID }) else { return } + let revision = goalRevision + let parts = Calendar.current.dateComponents([.hour, .minute], from: draft.time) + saving = true + saveError = nil + do { + try await model.saveSchedule(lp, socPct: draft.socPct, hour: parts.hour ?? 7, minute: parts.minute ?? 0, recurring: draft.recurring, days: draft.days, surplusUnlockPct: draft.surplusUnlockPct) + justSavedGoal = lp.id + if revision == goalRevision { scheduleNote = "Schedule saved." } + } catch { + if revision == goalRevision { + saveError = (error as? BoxAPIError)?.help ?? "Your box didn't confirm the change. Check the current settings before trying again." + } + } + saving = false + // An edit that landed while this one was on the wire goes out now. + if revision != goalRevision, goal != nil { await saveGoal() } + } + + private func removeGoal(_ lp: Loadpoint) async { + saving = true + saveError = nil + scheduleNote = "Removing goal…" + do { + try await model.removeSchedule(lp) + if justSavedGoal == lp.id { justSavedGoal = nil } + removedGoal = lp.id + goal = nil + } catch { + saveError = (error as? BoxAPIError)?.help ?? "Your box didn't confirm the change. Check the current settings before trying again." + } + saving = false + } + + // MARK: Only spare solar + + @ViewBuilder private func surplus(_ lp: Loadpoint) -> some View { + if site.canConfigure { + let overridden = lp.manualActive || lp.manualRestoreUnconfirmed + Toggle("Only spare solar", isOn: Binding( + get: { model.surplusShown(lp) }, + set: { on in + model.surplusDraft[lp.id] = on + Task { + await model.setSurplusOnly(lp, on) + model.surplusDraft[lp.id] = nil + } + } + )) + .disabled(sending || overridden) + Hint(overridden + ? (EVText.isPaused(lp) || lp.manualRestoreUnconfirmed ? "This rule resumes with the plan." : "Charge now overrides this rule. It resumes when you return to the plan.") + : model.surplusShown(lp) ? "No grid or home battery. Your target may not be reached in time." : "The plan may use grid power to reach your target.") + if isSending(.surplus, lp) { + Hint("Asking your box…") + } else { + outcome(.surplus, lp) + } + } else if lp.surplusOnly { + Hint("Charges from spare solar only.") + } + } + + // MARK: Home battery boost + + private func boostSection(_ lp: Loadpoint) -> some View { + DisclosureGroup("Home battery boost") { + VStack(alignment: .leading, spacing: 8) { + if !lp.boostActive { + if let draft = boost, draft.loadpointID == lp.id { + Text("Let the house battery charge the car for a while.").font(.callout) + Stepper("Keep \(draft.reservePct) % in the house battery", value: Binding( + get: { boost?.reservePct ?? EVText.boostReserveDefaultPct }, + set: { boost?.reservePct = $0 } + ), in: EVText.boostReserveMinPct...100, step: 5) + .disabled(sending) + HStack(spacing: 6) { + ForEach(EVText.boostDurations, id: \.seconds) { d in + let on = draft.durationS == d.seconds + Button(d.label) { boost?.durationS = d.seconds } + .font(.caption.weight(.semibold)) + .padding(.vertical, 5) + .padding(.horizontal, 9) + .foregroundStyle(on ? Theme.onAccent : Theme.fgDim) + .background(on ? Theme.accent : Theme.surfaceSunken, in: Capsule()) + .buttonStyle(.plain) + .disabled(sending) + } + } + HStack { + Button(sending ? "Asking your box…" : "Start boost") { + Task { + await model.boost(lp, reservePct: draft.reservePct, durationS: draft.durationS) + if case .applied = model.command { boost = nil } + } + } + .buttonStyle(.primary) + .disabled(sending) + Button("Cancel") { boost = nil }.buttonStyle(.quiet).disabled(sending) + } + Hint("Ends when the time is up, the house battery reaches the reserve, or you stop it.") + } else if lp.manualActive { + Hint("Available after returning to the plan.") + } else if model.surplusShown(lp) { + Hint("Not while the charger uses spare solar only.") + } else { + Button("Boost from the house battery") { + boost = BoostDraft(loadpointID: lp.id, reservePct: EVText.boostReserveDefaultPct, durationS: EVText.boostDurationDefault) + } + .buttonStyle(.outline) + } + outcome(.boost, lp) + } + } + .padding(.top, 6) + } + .font(.callout) + } + + // MARK: Charging ahead + + @ViewBuilder private func windows(_ lp: Loadpoint) -> some View { + let planReady = !lp.manualActive && !lp.planPending && !model.planPending && !lp.planOutdated && !model.planOutdated + let ahead = model.windows[lp.id] ?? [] + if planReady, !stale, !ahead.isEmpty { + ControlCard { + Kicker("Charging ahead") + ForEach(ahead) { w in + HStack { + Text("\(EVText.clock(w.fromMs))–\(EVText.clock(w.toMs))").font(Theme.number(13, weight: .regular)) + Spacer() + if let wh = w.energyWh { + Text("\((wh / 1000).formatted(.number.precision(.fractionLength(0...1)))) kWh").font(.footnote) + } else if let peak = w.peakW { + Text("up to \(PowerFormat.text(peak))").font(.footnote) + } + } + } + } + } else if planReady, model.planMissing { + Hint("Charging times aren't readable right now.") + } + } +} + +/// A group of related controls, set off like a card. +private struct ControlCard: View { + @ViewBuilder var content: Content + + var body: some View { + VStack(alignment: .leading, spacing: 10) { content } + .padding(14) + .frame(maxWidth: .infinity, alignment: .leading) + .background(Theme.surfaceRaised, in: RoundedRectangle(cornerRadius: Theme.radius)) + .overlay(RoundedRectangle(cornerRadius: Theme.radius).strokeBorder(Theme.line, lineWidth: 1)) + } +} diff --git a/appleApp/FTW/UI/FlowDiagram.swift b/appleApp/FTW/UI/FlowDiagram.swift new file mode 100644 index 0000000..e26859b --- /dev/null +++ b/appleApp/FTW/UI/FlowDiagram.swift @@ -0,0 +1,174 @@ +import FTWKit +import SwiftUI + +/// The house and what flows through it, drawn from the same nodes the box's +/// own dashboard draws: solar top left, battery top right, grid bottom left, +/// the car bottom right, the house in the middle. +/// +/// Dots move along a line only while readings are live. A cached or quiet +/// view holds still, because motion says power is flowing right now. +struct FlowDiagram: View { + let readings: Flow.Readings + let moving: Bool + let tap: (Flow.Role) -> Void + + private static let height: CGFloat = 340 + + var body: some View { + GeometryReader { geo in + let layout = Layout(size: geo.size, nodes: readings.nodes) + ZStack { + TimelineView(.animation(minimumInterval: 1.0 / 30, paused: !moving)) { context in + Canvas { canvas, _ in + draw(canvas, layout: layout, at: context.date.timeIntervalSinceReferenceDate) + } + } + .accessibilityHidden(true) + + Hub(loadKw: readings.loadKw, selfPoweredPct: readings.selfPoweredPctToday) + .position(layout.hub) + .onTapGesture { tap(.load) } + + ForEach(readings.nodes) { node in + Bubble(node: node) + .position(layout.points[node.id] ?? layout.hub) + .onTapGesture { if node.tappable { tap(node.role) } } + } + } + } + .frame(height: Self.height) + .frame(maxWidth: 520) + .frame(maxWidth: .infinity) + } + + /// Where each node sits. Several nodes in one corner stack toward the + /// middle, the way a site with two inverters shows two suns. + struct Layout { + let hub: CGPoint + let points: [String: CGPoint] + + init(size: CGSize, nodes: [Flow.Node]) { + let w = size.width, h = size.height + hub = CGPoint(x: w / 2, y: h / 2) + var points = [String: CGPoint]() + var counts = [String: Int]() + for node in nodes { + let key = "\(node.corner)" + let n = counts[key, default: 0] + counts[key] = n + 1 + let x: CGFloat = (node.corner == .topLeft || node.corner == .bottomLeft) ? w * 0.17 : w * 0.83 + let top = node.corner == .topLeft || node.corner == .topRight + let y: CGFloat = top ? h * 0.16 + CGFloat(n) * 96 : h * 0.84 - CGFloat(n) * 96 + points[node.id] = CGPoint(x: x, y: y) + } + self.points = points + } + } + + private func draw(_ canvas: GraphicsContext, layout: Layout, at time: TimeInterval) { + for node in readings.nodes { + guard let from = layout.points[node.id] else { continue } + var line = Path() + line.move(to: from) + line.addLine(to: layout.hub) + let active = node.kw * 1000 > Flow.idleW + let color = Theme.color(node.tone) + canvas.stroke(line, with: .color(active ? color.opacity(0.45) : Theme.line), style: StrokeStyle(lineWidth: active ? 2 : 1.5, dash: active ? [] : [3, 5])) + guard active, moving else { continue } + // Speed grows with power but never races: a kettle and a car + // charger are both readable at a glance. + let speed = 0.25 + min(1.2, log10(1 + node.kw) * 0.9) + let dots = 4 + for i in 0.. 0 ? "drawing" : "exporting" } + figure("Solar", Contract.FID.pvW, site) { _ in "producing" } + figure("Battery", Contract.FID.batteryW, site) { $0 > 0 ? "charging" : "supplying" } + figure("Home", Contract.FID.loadW, site) { _ in "using" } + } + } else { + Text("Nothing from your box yet.").foregroundStyle(Theme.fgDim) + } + } else { + Text("No home is paired on this Mac.").foregroundStyle(Theme.fgDim) + } + Divider() + HStack { + Button("Open FTW") { + NSApplication.shared.activate() + if let window = NSApplication.shared.windows.first(where: \.canBecomeMain) { + window.makeKeyAndOrderFront(nil) + } else { + openWindow(id: FTWApp.mainWindow) + } + } + Spacer() + Button("Quit") { NSApplication.shared.terminate(nil) } + } + } + .padding(14) + .frame(width: 320) + } + + private func figure(_ label: String, _ fid: Int, _ site: SiteModel, words: (Double) -> String) -> some View { + let reading = site.session.fields[fid].map { fid == Contract.FID.pvW ? abs($0) : $0 } + let value = reading.map(PowerFormat.text) ?? "—" + let word = reading.map { PowerFormat.direction($0) == .idle ? "idle" : words($0) } ?? "" + return GridRow { + Text(label).foregroundStyle(Theme.fgDim) + Text(value).font(Theme.number(14)) + Text(word).foregroundStyle(Theme.fgMuted) + } + } +} +#endif diff --git a/appleApp/FTW/UI/HistoryView.swift b/appleApp/FTW/UI/HistoryView.swift new file mode 100644 index 0000000..47ecdff --- /dev/null +++ b/appleApp/FTW/UI/HistoryView.swift @@ -0,0 +1,311 @@ +import Charts +import FTWKit +import SwiftUI + +/// What the house used, made, bought and sold, then power minute by minute. +struct HistoryView: View { + let home: HomeModels + + var body: some View { + Group { + if home.site.hasPassthrough { + EnergySection(energy: home.energy) + Divider().overlay(Theme.line) + } + PowerSection(history: home.history) + } + .onAppear { + home.energy.activate() + home.history.activate() + } + .onDisappear { + home.energy.deactivate() + home.history.deactivate() + } + } +} + +// MARK: Energy, day by day + +private struct EnergySection: View { + let energy: EnergyModel + @State private var shown: Series = .load + + enum Series: CaseIterable { + case load, pv, bought, sold + + var label: String { + switch self { + case .load: return "Used at home" + case .pv: return "Made by solar" + case .bought: return "Bought" + case .sold: return "Sold" + } + } + + var note: String { + switch self { + case .load: return "everything the house drew" + case .pv: return "what the panels produced" + case .bought: return "taken from the grid" + case .sold: return "sent back to the grid" + } + } + + var color: Color { + switch self { + case .load: return Theme.fgDim + case .pv: return Theme.generation + case .bought: return Theme.importing + case .sold: return Theme.exporting + } + } + + func wh(_ t: EnergyModel.Totals) -> Double { + switch self { + case .load: return t.loadWh + case .pv: return t.pvWh + case .bought: return t.importWh + case .sold: return t.exportWh + } + } + + func wh(_ d: EnergyModel.Day) -> Double { + switch self { + case .load: return d.loadWh + case .pv: return d.pvWh + case .bought: return d.importWh + case .sold: return d.exportWh + } + } + } + + var body: some View { + let totals = energy.totals + let hasDays = !energy.days.isEmpty + HStack { + Kicker(energy.range.title) + Spacer() + Segments(options: EnergyModel.Range.allCases, selected: energy.range, label: \.label) { energy.select($0) } + } + LazyVGrid(columns: [GridItem(.flexible()), GridItem(.flexible())], spacing: 10) { + ForEach(Series.allCases, id: \.self) { series in + let parts = EnergyFormat.parts(series.wh(totals)) + Button { shown = series } label: { + VStack(alignment: .leading, spacing: 3) { + Text(series.label).font(.caption.weight(.semibold)).foregroundStyle(Theme.fgDim) + Group { + if hasDays { + Text(parts.text).font(Theme.number(22)) + Text(" \(parts.unit)").font(.caption) + } else { + Text("—").font(Theme.number(22)) + } + } + .foregroundStyle(series == .load ? Theme.fg : series.color) + Text(series.note).font(.caption2).foregroundStyle(Theme.fgMuted) + } + .padding(12) + .frame(maxWidth: .infinity, alignment: .leading) + .background(Theme.surfaceRaised, in: RoundedRectangle(cornerRadius: Theme.radiusSmall)) + .overlay(RoundedRectangle(cornerRadius: Theme.radiusSmall).strokeBorder(shown == series ? series.color : Theme.line, lineWidth: shown == series ? 2 : 1)) + } + .buttonStyle(.plain) + .accessibilityAddTraits(shown == series ? .isSelected : []) + } + } + if hasDays, energy.days.count > 1 { + Text("\(shown.label), kWh per day").font(.caption).foregroundStyle(Theme.fgDim) + Chart(energy.days) { day in + BarMark(x: .value("Day", day.day), y: .value("kWh", shown.wh(day) / 1000)) + .foregroundStyle(shown.color) + } + .chartXAxis { + AxisMarks(values: .automatic(desiredCount: 6)) { value in + AxisValueLabel { + if let day = value.as(String.self) { Text(Self.dayLabel(day)).font(.caption2) } + } + } + } + .chartYAxis { + AxisMarks(position: .leading) { _ in + AxisGridLine().foregroundStyle(Theme.line) + AxisValueLabel() + } + } + .frame(height: 140) + } + note(totals: totals, hasDays: hasDays) + } + + @ViewBuilder private func note(totals: EnergyModel.Totals, hasDays: Bool) -> some View { + if let error = energy.error { + Hint(error) + } else if !hasDays { + Hint(energy.loading || !energy.loaded ? "Reading your box…" : "Nothing recorded for this period yet.") + } else { + VStack(alignment: .leading, spacing: 4) { + if let share = energy.solarSharePct { + Hint("Solar made \(share)% as much energy as the home used.") + } + if totals.batChargedWh > 0 || totals.batDischargedWh > 0 { + Hint("The battery took in \(EnergyFormat.label(totals.batChargedWh)) and gave back \(EnergyFormat.label(totals.batDischargedWh)).") + } + if energy.range != .today { + Hint("Today is still running.", tone: Theme.fgMuted) + } + } + } + } + + /// "2026-09-24" to "24 Sep", short enough for thirty bars. + static func dayLabel(_ day: String) -> String { + let f = DateFormatter() + f.locale = Locale(identifier: "en_US_POSIX") + f.dateFormat = "yyyy-MM-dd" + guard let date = f.date(from: day) else { return day } + return date.formatted(.dateTime.day().month(.abbreviated)) + } +} + +// MARK: Power, minute by minute + +private struct PowerSection: View { + let history: HistoryModel + + private struct Trace { + let name: String + let label: String + let color: Color + } + + private let traces = [ + Trace(name: "grid_w", label: "Grid", color: Theme.importing), + Trace(name: "pv_w", label: "Solar", color: Theme.generation), + Trace(name: "battery_w", label: "Battery", color: Theme.storage), + Trace(name: "load_w", label: "House", color: Theme.fgDim), + ] + + var body: some View { + HStack { + Kicker("Power, minute by minute") + Spacer() + Segments(options: HistoryModel.Range.allCases, selected: history.range, label: \.label) { history.select($0) } + } + Group { + if let frame = history.frame, frame.points > 0 { + chart(frame) + } else { + Text(history.loaded ? "Nothing recorded for this range yet." : "Reading your box…") + .font(.footnote) + .foregroundStyle(Theme.fgDim) + .frame(maxWidth: .infinity, minHeight: 200) + .background(Theme.surfaceSunken, in: RoundedRectangle(cornerRadius: Theme.radiusSmall)) + } + } + readout + if !history.note.isEmpty { + Hint(history.note) + } + } + + private func chart(_ frame: HistoryGeometry.Frame) -> some View { + let step = max(1, frame.points / 400) + let indices = Array(stride(from: 0, to: frame.points, by: step)) + let spanMs = Double(frame.points) * frame.stepMs + // A day reads as clock times; anything longer as dates. + let format: Date.FormatStyle = spanMs <= 36 * 3_600_000 ? .dateTime.hour().minute() : .dateTime.day().month(.abbreviated) + return Chart { + ForEach(traces, id: \.name) { trace in + ForEach(indices, id: \.self) { i in + if let v = frame.value(trace.name, at: i) { + LineMark( + x: .value("Time", Date(timeIntervalSince1970: frame.time(at: i) / 1000)), + y: .value("Watts", Double(v)), + series: .value("Series", trace.label) + ) + .foregroundStyle(trace.color) + .lineStyle(StrokeStyle(lineWidth: trace.name == "load_w" ? 1 : 1.5)) + } + } + } + RuleMark(y: .value("Zero", 0)).foregroundStyle(Theme.line) + if let at = history.cursorAtMs { + RuleMark(x: .value("Cursor", Date(timeIntervalSince1970: at / 1000))) + .foregroundStyle(Theme.fgMuted) + } + } + .chartYAxis { + AxisMarks(position: .leading) { value in + AxisGridLine().foregroundStyle(Theme.line) + AxisValueLabel { + if let w = value.as(Double.self) { + Text(w == 0 ? "0" : "\(PowerFormat.scale(w))\(w > 0 ? " in" : " out")").font(Theme.number(9, weight: .regular)) + } + } + } + } + .chartXAxis { + AxisMarks(values: .automatic(desiredCount: 3)) { _ in + AxisGridLine().foregroundStyle(Theme.line) + AxisValueLabel(format: format) + } + } + .chartOverlay { proxy in + GeometryReader { geo in + Rectangle().fill(.clear).contentShape(Rectangle()) + .gesture( + DragGesture(minimumDistance: 0) + .onChanged { drag in + guard let plot = proxy.plotFrame else { return } + let x = drag.location.x - geo[plot].origin.x + guard let date: Date = proxy.value(atX: x) else { return } + let index = Int(((date.timeIntervalSince1970 * 1000 - frame.startMs) / frame.stepMs).rounded()) + history.cursor = min(frame.points - 1, max(0, index)) + } + .onEnded { _ in history.cursor = nil } + ) + } + } + .frame(height: 220) + } + + private var readout: some View { + VStack(alignment: .leading, spacing: 8) { + Text(history.cursorAtMs.map(Clock.dayAndTime) ?? "Latest") + .font(.caption.weight(.semibold)) + .foregroundStyle(Theme.fgDim) + LazyVGrid(columns: [GridItem(.flexible()), GridItem(.flexible())], alignment: .leading, spacing: 8) { + ForEach(traces, id: \.name) { trace in + let watts = history.value(trace.name) + HStack(alignment: .top, spacing: 8) { + RoundedRectangle(cornerRadius: 2).fill(trace.color).frame(width: 4, height: 30) + VStack(alignment: .leading, spacing: 1) { + Text(trace.label).font(.caption2).foregroundStyle(Theme.fgDim) + if let watts { + let parts = PowerFormat.parts(watts) + Text(parts.text).font(Theme.number(15)) + Text(" \(parts.unit)").font(.caption2) + } else { + Text("—").font(Theme.number(15)) + } + Text(words(trace.name, watts)).font(.caption2).foregroundStyle(Theme.fgMuted) + } + } + .accessibilityElement(children: .combine) + } + } + } + } + + /// Never a minus sign; a direction word instead. + private func words(_ name: String, _ watts: Double?) -> String { + guard let watts else { return "no reading" } + if name == "load_w" { return "used" } + let direction = PowerFormat.direction(watts) + if direction == .idle { return "idle" } + switch name { + case "pv_w": return "generated" + case "battery_w": return direction == .into ? "charged" : "supplied" + default: return direction == .into ? "drawn" : "exported" + } + } +} diff --git a/appleApp/FTW/UI/LiveSheet.swift b/appleApp/FTW/UI/LiveSheet.swift new file mode 100644 index 0000000..e79ceba --- /dev/null +++ b/appleApp/FTW/UI/LiveSheet.swift @@ -0,0 +1,101 @@ +import Charts +import FTWKit +import SwiftUI + +/// Which bubble was tapped. Everything else follows from it. +enum LiveRole: String, Identifiable { + case grid, pv, battery, load + var id: String { rawValue } +} + +/// One part of the house over the last two minutes. The line moves on news +/// and freezes on silence: a repeated cache value moves nothing. +struct LiveSheet: View { + let site: SiteModel + let role: LiveRole + let fields: [Int: Double] + @Environment(\.dismiss) private var dismiss + + private struct Spec { + let title: String + let fid: Int + let signed: Bool + let words: (Double) -> String + } + + private var spec: Spec { + switch role { + case .grid: return Spec(title: "Grid", fid: Contract.FID.gridW, signed: true) { abs($0) < 20 ? "balanced" : $0 > 0 ? "drawing from the grid" : "exporting to the grid" } + case .pv: return Spec(title: "Solar", fid: Contract.FID.pvW, signed: false) { abs($0) < 20 ? "not producing" : "producing" } + case .battery: return Spec(title: "Battery", fid: Contract.FID.batteryW, signed: true) { abs($0) < 20 ? "resting" : $0 > 0 ? "charging" : "discharging" } + case .load: return Spec(title: "Home", fid: Contract.FID.loadW, signed: false) { _ in "used by the house" } + } + } + + var body: some View { + let spec = spec + let raw = fields[spec.fid] + let live = site.isLive + let color = Theme.color(Flow.tone(Flow.Role(rawValue: role.rawValue) ?? .load, raw)) + // Reading the stream's clock ties this view to every new frame. + let _ = site.session.uptimeMs + let points = site.recentField(spec.fid).map { (t: $0.t, v: spec.signed ? $0.v : abs($0.v)) } + NavigationStack { + VStack(alignment: .leading, spacing: 12) { + if let raw { + let parts = PowerFormat.parts(spec.signed ? raw : abs(raw)) + HStack(alignment: .firstTextBaseline, spacing: 6) { + Text(parts.text).font(Theme.number(44, weight: .bold)).foregroundStyle(color) + Text(parts.unit).font(.title3).foregroundStyle(color) + if role == .battery, let soc = fields[Contract.FID.batterySoc] { + Text("\(PowerFormat.soc(soc))%").font(Theme.number(20)).foregroundStyle(Theme.fgDim) + } + } + Text(spec.words(spec.signed ? raw : abs(raw)) + (live ? "" : " · last known")) + .foregroundStyle(Theme.fgDim) + chart(points, color: color) + .frame(height: 200) + Hint("last two minutes", tone: Theme.fgMuted) + } else { + Hint("No reading from your box yet.") + } + Spacer() + } + .padding(20) + .navigationTitle(spec.title) + #if os(iOS) + .navigationBarTitleDisplayMode(.inline) + #endif + .toolbar { + ToolbarItem(placement: .confirmationAction) { + Button("Close") { dismiss() } + } + } + } + .presentationDetents([.medium, .large]) + } + + private func chart(_ points: [(t: Double, v: Double)], color: Color) -> some View { + let end = site.nowMs + return Chart { + ForEach(points, id: \.t) { p in + LineMark(x: .value("Time", Date(timeIntervalSince1970: p.t / 1000)), y: .value("Watts", p.v)) + .foregroundStyle(color) + .interpolationMethod(.monotone) + } + if spec.signed { + RuleMark(y: .value("Zero", 0)).foregroundStyle(Theme.line) + } + } + .chartXScale(domain: Date(timeIntervalSince1970: (end - 120_000) / 1000)...Date(timeIntervalSince1970: end / 1000)) + .chartXAxis(.hidden) + .chartYAxis { + AxisMarks(position: .leading) { value in + AxisGridLine().foregroundStyle(Theme.line) + AxisValueLabel { + if let w = value.as(Double.self) { Text(PowerFormat.scale(w)).font(Theme.number(10, weight: .regular)) } + } + } + } + } +} diff --git a/appleApp/FTW/UI/NowView.swift b/appleApp/FTW/UI/NowView.swift new file mode 100644 index 0000000..1305028 --- /dev/null +++ b/appleApp/FTW/UI/NowView.swift @@ -0,0 +1,285 @@ +import FTWKit +import SwiftUI + +/// Now, the first screen. A glance: one sentence at the top, the house +/// under it, then price, what happens next, today and the fuse. +struct NowView: View { + let app: AppModel + let home: HomeModels + let openCharger: (String?) -> Void + let open: (HomeTab) -> Void + @State private var liveRole: LiveRole? + + private var site: SiteModel { home.site } + + var body: some View { + Group { + switch site.session.phase { + case .booting: + Title("Your box is starting") + Text("This can take a few minutes after an update while it tidies its database. Nothing is wrong. It will appear here as soon as it is ready.") + if let boot = site.session.boot { + Hint("\(Self.bootWords(boot.phase)) · \(boot.pct)%") + } + case .terminated: + Title("Access ended") + Text(site.session.terminated == .revoked ? "Your access to this home was withdrawn by its owner." : "This session ended.") + if site.session.terminated == .revoked { + Button("Your box won't let this phone in?") { app.recovering = true }.buttonStyle(.quiet) + } + default: + if site.session.fields.isEmpty { + nothingYet + } else { + house + } + } + } + .onAppear { + home.status.activate() + home.prices.activate() + home.plan.activate() + home.savings.activate() + } + .onDisappear { + home.status.deactivate() + home.prices.deactivate() + home.plan.deactivate() + home.savings.deactivate() + } + .sheet(item: $liveRole) { role in + LiveSheet(site: site, role: role, fields: flowFields) + } + } + + /// Paired, and not one reading has ever arrived. A hollow house would + /// read as a home at zero, so this says what is true instead. + @ViewBuilder private var nothingYet: some View { + Title("Nothing from your box yet") + Text(app.connectHelp ?? "This phone is paired to it and keeps trying on its own. Your house appears here as soon as the box answers.") + if app.connectHelp != nil { + Button("Get this phone back in") { app.recovering = true }.buttonStyle(.primary) + } else if !app.isDemo { + Button("Your box won't let this phone in?") { app.recovering = true }.buttonStyle(.quiet) + } + } + + // MARK: The house + + private var live: Bool { site.isLive } + + private var flowFields: [Int: Double] { + Flow.withLoadpointEV(site.session.fields, evW: home.charging.chargeW) + } + + private var watchingStatus: Bool { + site.documentVisible && site.session.phase == .streaming && site.hasPassthrough + } + + private var statusLive: Bool { home.status.fresh && live && watchingStatus } + + private var readings: Flow.Readings { + if let status = home.status.status, statusLive || !live { + return Flow.readings(status: status) + } + return Flow.readings(fields: flowFields) + } + + @ViewBuilder private var house: some View { + if let help = app.connectHelp { + Card { + Text(help) + Button("Get this phone back in") { app.recovering = true }.buttonStyle(.quiet) + } + } + if site.session.needsUpdate { + Card { Text("This app is older than your box. Some things are hidden until it updates.") } + } + Text(Explanation.explain(fields: flowFields, dispatchBlockedBy: site.session.dispatchBlockedBy, ceilingW: site.ceilingW).headline) + .font(.title3.weight(.semibold)) + .foregroundStyle(Theme.fg) + .fixedSize(horizontal: false, vertical: true) + .accessibilityAddTraits(.isHeader) + + // Motion claims power is flowing right now, so a cached view holds still. + FlowDiagram(readings: readings, moving: live) { role in + switch role { + case .ev: openCharger(nil) + case .grid: if live { liveRole = .grid } + case .pv: if live { liveRole = .pv } + case .battery: if live { liveRole = .battery } + case .load: if live { liveRole = .load } + } + } + if home.status.status != nil, !statusLive { + Hint("Device details are out of date.\(live ? " Showing live totals." : "")") + } + NowOutlook(site: site, home: home, statusLive: statusLive, open: open) + } + + static func bootWords(_ phase: String) -> String { + switch phase { + case "vacuum": return "tidying its records" + case "migrate": return "bringing its records up to date" + case "drivers": return "waking the equipment" + default: return "getting ready" + } + } +} + +/// Price, what FTW does next, today and the fuse: the rest of a glance. +private struct NowOutlook: View { + let site: SiteModel + let home: HomeModels + let statusLive: Bool + let open: (HomeTab) -> Void + + var body: some View { + if let prices = home.prices.prices { + Card { + PriceChart(prices: prices, nowMs: site.nowMs, compact: true) + if home.prices.hasHole { + Hint("Some hours are missing their price.") + } else if prices.stale { + Hint("Tomorrow's rates aren't published yet.") + } + } + } + planCard + if let today = today { + todayCard(today) + } + if let status = home.status.status, let fuse = Flow.fuse(status: status) { + fuseCard(fuse) + } + } + + private var planCard: some View { + let brief = PlanText.brief(home.plan.plan, nowMs: site.nowMs, mode: home.plan.actualMode, dispatchBlockedBy: site.session.dispatchBlockedBy, clock: Clock.time) + return Card { + HStack(alignment: .top) { + VStack(alignment: .leading, spacing: 2) { + Kicker("Automation") + Text("What FTW does next").font(.headline) + } + Spacer() + Text(brief.stateLabel) + .font(.caption.weight(.semibold)) + .padding(.horizontal, 8) + .padding(.vertical, 3) + .foregroundStyle(brief.tone == .active ? Theme.exporting : brief.tone == .warn ? Theme.generation : Theme.fgDim) + .background(Theme.surfaceSunken, in: Capsule()) + } + Text(brief.action).font(.body.weight(.semibold)) + if let time = brief.time { Hint(time) } + if let reason = brief.reason { Hint("\(reason).") } + Hint(brief.constraint, tone: Theme.fgMuted) + Button("Open full plan →") { open(.plan) }.buttonStyle(.quiet) + } + } + + private struct Today { + let importWh: Double + let exportWh: Double + let pvWh: Double + } + + private var today: Today? { + guard let t = home.status.status?["energy"]?["today"], t.object != nil else { return nil } + return Today(importWh: EnergyFormat.wholeWh(t["import_wh"]?.number), exportWh: EnergyFormat.wholeWh(t["export_wh"]?.number), pvWh: EnergyFormat.wholeWh(t["pv_wh"]?.number)) + } + + private func todayCard(_ today: Today) -> some View { + Card { + Kicker(statusLive ? "Since midnight" : "Last known") + Text(statusLive ? "Today" : "Last totals").font(.headline) + if !statusLive, let at = home.status.receivedAtMs { + Hint("Energy totals last updated \(Clock.dayAndTime(at)).") + } + LazyVGrid(columns: [GridItem(.flexible()), GridItem(.flexible())], alignment: .leading, spacing: 12) { + EnergyTile(label: "Imported", wh: today.importWh, color: Theme.importing) + EnergyTile(label: "Exported", wh: today.exportWh, color: Theme.exporting) + EnergyTile(label: "Solar", wh: today.pvWh, color: Theme.generation) + if let savings = home.savings.periods { + VStack(alignment: .leading, spacing: 2) { + Text("Saved \(home.prices.currency)").font(.caption).foregroundStyle(Theme.fgDim) + if savings.today.available { + Text(Savings.compact(savings.today.savedMinor)) + .font(Theme.number(20)) + .foregroundStyle(savings.today.savedMinor >= 0 ? Theme.exporting : Theme.importing) + } else { + Text("—").font(Theme.number(20)) + } + if savings.week.available { + Text("\(Savings.compact(savings.week.savedMinor)) this week").font(.caption).italic().foregroundStyle(Theme.fgDim) + } + } + } + } + Button("Open history →") { open(.history) }.buttonStyle(.quiet) + } + } + + private func fuseCard(_ fuse: Flow.Fuse) -> some View { + Card { + Kicker(statusLive ? "Live safety" : "Last known") + Text("Fuse").font(.headline) + if !statusLive, let at = home.status.receivedAtMs { + Hint("Fuse readings last updated \(Clock.dayAndTime(at)).") + } + if !fuse.phases.isEmpty { + ForEach(fuse.phases) { phase in + FuseBar(label: phase.label, amps: phase.amps, pct: phase.pct, exporting: phase.exporting) + } + } else if let fallback = fuse.fallback { + FuseBar(label: "\(Int(fuse.maxAmps)) A", amps: fallback.amps, pct: fallback.pct, exporting: false) + } + } + } +} + +struct EnergyTile: View { + let label: String + let wh: Double + let color: Color + + var body: some View { + let parts = EnergyFormat.parts(wh) + VStack(alignment: .leading, spacing: 2) { + Text(label).font(.caption).foregroundStyle(Theme.fgDim) + (Text(parts.text).font(Theme.number(20)) + Text(" \(parts.unit)").font(.caption)) + .foregroundStyle(color) + } + } +} + +private struct FuseBar: View { + let label: String + let amps: Double + let pct: Double + let exporting: Bool + + var body: some View { + VStack(alignment: .leading, spacing: 4) { + HStack { + Text(label).font(.caption.weight(.semibold)).foregroundStyle(Theme.fgDim) + Spacer() + Text("\(PowerFormat.fixed(abs(amps), 1)) A").font(Theme.number(13)) + } + GeometryReader { geo in + ZStack(alignment: .leading) { + Capsule().fill(Theme.surfaceSunken) + Capsule().fill(color).frame(width: geo.size.width * min(1, max(0, pct / 100))) + } + } + .frame(height: 6) + } + .accessibilityElement(children: .combine) + } + + private var color: Color { + if pct >= 90 { return Theme.importing } + if pct >= 70 { return Theme.generation } + return exporting ? Theme.exporting : Theme.storage + } +} diff --git a/appleApp/FTW/UI/PairView.swift b/appleApp/FTW/UI/PairView.swift new file mode 100644 index 0000000..b326090 --- /dev/null +++ b/appleApp/FTW/UI/PairView.swift @@ -0,0 +1,204 @@ +import FTWKit +import SwiftUI +import UniformTypeIdentifiers + +/// Pairing, the first thing anyone sees. Scan, then Face ID. Everything that +/// can fail does so before the passkey prompt. +struct PairView: View { + let app: AppModel + @State private var model: PairModel + @State private var importing = false + + init(app: AppModel) { + self.app = app + _model = State(initialValue: PairModel(app: app)) + } + + var body: some View { + ScrollView { + VStack(alignment: .leading, spacing: 16) { + content + } + .padding(20) + .frame(maxWidth: 520, alignment: .leading) + .frame(maxWidth: .infinity) + } + .background(Theme.surface) + .fileImporter(isPresented: $importing, allowedContentTypes: [.image]) { result in + read(result) + } + } + + /// A screen reached from a home says what this phone cannot do. + private var problem: String? { app.recovering ? app.connectHelp : nil } + private var canDismiss: Bool { app.recovering } + + @ViewBuilder private var content: some View { + switch model.stage { + case .scanning: + QRScanner { code in Task { await model.pair(code) } } + .frame(height: 360) + .clipShape(RoundedRectangle(cornerRadius: Theme.radius)) + .overlay(RoundedRectangle(cornerRadius: 24).strokeBorder(Theme.accent, lineWidth: 3).padding(60)) + Hint("In your box's local dashboard, open Settings → FTW app. Hold the pairing QR inside the frame.") + Button("Cancel") { model.cancel() }.buttonStyle(.quiet) + + case .pairing: + Title("Securing this phone") + Text("Confirm with Face ID or Touch ID if your phone asks.") + Button("Cancel") { model.cancel() }.buttonStyle(.quiet) + + case .demoing: + Title("Starting the demo") + Text("Loading a simulated home.") + + case .recovering: + Title("Checking") + Text("Asking your passkey what it can open.") + Button("Cancel") { model.cancel() }.buttonStyle(.quiet) + + case .choosing: + Title(model.recovered.count == 1 ? "Your home is here" : "Pick a home to open") + Text("Your passkey opened a sealed copy. Nothing was sent to your box.") + ForEach(model.recovered) { home in + Button { model.adopt(home) } label: { + Text("Open \(home.label) ") + Text(home.fingerprint).font(Theme.number(15)) + } + .buttonStyle(.primary) + } + Button("Not now") { model.cancel() }.buttonStyle(.quiet) + + case .intro: + if let fingerprint = model.offeredFingerprint { + offer(fingerprint) + } else { + intro + } + } + } + + /// A link that arrived from outside, shown with the box it names before + /// anything trusts it. + @ViewBuilder private func offer(_ fingerprint: String) -> some View { + let known = model.known + Title(known != nil ? "Connect to a different box?" : "Connect this box?") + Group { + if let known { + Text("This link points at box ") + Text(fingerprint).font(Theme.number(16)) + Text(". Connecting it replaces \(known.label) as the home this app shows and controls. Your key for \(known.label) stays on this phone.") + } else { + Text("This link points at box ") + Text(fingerprint).font(Theme.number(16)) + Text(". Only continue if you just opened Settings → FTW app and chose Show pairing code in this box's local dashboard.") + } + } + if let message = model.message { Problem(message) } + Button(known != nil ? "Connect \(fingerprint)" : "Connect this box") { + Task { await model.acceptOffer() } + } + .buttonStyle(.primary) + Button("Not now") { model.declineOffer() }.buttonStyle(.quiet) + } + + @ViewBuilder private var intro: some View { + Title(problem != nil ? "Get this phone back in" : model.canOpen ? "Welcome back" : "Connect FTW") + if let problem { + Text(problem) + } else if model.canOpen { + Text("Your key is still on this phone. Nothing to set up again.") + } else { + Text("Connect your own box, open a home saved with your passkey, or try a live simulated home first.") + } + if let message = model.message { Problem(message) } + + if problem == nil, !canDismiss, !model.canOpen { + demoOffer + } + + if model.canOpen, let known = model.known { + Button("Open \(known.label)") { model.openKnown() }.buttonStyle(.primary) + Button("Scan a new pairing QR") { model.stage = .scanning }.buttonStyle(.quiet) + Hint("Use a new QR from Settings → FTW app if this key no longer works.") + } else { + setup + Button { Task { await model.recover() } } label: { + VStack(alignment: .leading, spacing: 4) { + Text("Open with your passkey").font(.body.weight(.semibold)).foregroundStyle(Theme.fg) + Text("Used FTW before? Ask Face ID or Touch ID for a saved home.").font(.footnote).foregroundStyle(Theme.fgDim) + } + .frame(maxWidth: .infinity, alignment: .leading) + } + .buttonStyle(.outline) + } + + if canDismiss { + Button("Not now") { model.dismiss() }.buttonStyle(.quiet) + } + } + + private var demoOffer: some View { + Card { + Kicker("Interactive demo") + Text("See a home running").font(.title3.weight(.semibold)) + Text("Explore live solar, battery, grid, EV charging, plans and history. The data is simulated and nothing is saved.") + .foregroundStyle(Theme.fgDim) + Button("Try the live demo") { model.tryDemo() }.buttonStyle(.primary) + } + } + + private var setup: some View { + Card { + Text("Connect your own box").font(.title3.weight(.semibold)) + Hint("The QR code is inside FTW Settings. It is not printed on the Raspberry Pi or its case.") + VStack(alignment: .leading, spacing: 6) { + Text("1. Open your box's local FTW dashboard while on your home network.") + Text("2. Go to Settings → FTW app.") + Text("3. Choose Show pairing code, then scan the QR here.") + } + .font(.callout) + Hint("Next, a supported device asks for Face ID or Touch ID to protect your FTW key. There is no FTW account or password. If that passkey supports recovery and the save reaches Sourceful, FTW keeps a sealed recovery copy that Sourceful cannot open. If not, pairing still works; a new Settings QR is the way back.") + Button("Scan the pairing QR") { model.stage = .scanning }.buttonStyle(.primary) + #if os(macOS) + Button("Read the QR from a picture") { importing = true }.buttonStyle(.quiet) + Hint("On a Mac, a screenshot of the code works as well as the camera.") + #endif + DisclosureGroup("Can't see Show pairing code?") { + Hint("Turn on Let the FTW app connect to this box, save, and restart the box first.") + .frame(maxWidth: .infinity, alignment: .leading) + } + .font(.footnote) + } + } + + private func read(_ result: Result) { + guard case .success(let url) = result else { return } + let scoped = url.startAccessingSecurityScopedResource() + defer { if scoped { url.stopAccessingSecurityScopedResource() } } + if let code = QRImage.pairingCode(at: url) { + Task { await model.pair(code) } + } else { + model.noCodeFound() + } + } +} + +struct Title: View { + let text: String + init(_ text: String) { self.text = text } + + var body: some View { + Text(text) + .font(.largeTitle.weight(.bold)) + .foregroundStyle(Theme.fg) + .fixedSize(horizontal: false, vertical: true) + } +} + +struct Problem: View { + let text: String + init(_ text: String) { self.text = text } + + var body: some View { + Text(text) + .font(.callout) + .foregroundStyle(Theme.importing) + .fixedSize(horizontal: false, vertical: true) + } +} diff --git a/appleApp/FTW/UI/PlanView.swift b/appleApp/FTW/UI/PlanView.swift new file mode 100644 index 0000000..db05cf9 --- /dev/null +++ b/appleApp/FTW/UI/PlanView.swift @@ -0,0 +1,204 @@ +import FTWKit +import SwiftUI + +/// What the box means to do, how the home is run, and what power costs. +struct PlanView: View { + let home: HomeModels + @State private var showAdvanced = false + + private var plan: PlanModel { home.plan } + private var site: SiteModel { home.site } + + var body: some View { + Group { + Text(PlanText.headline(plan.plan, nowMs: site.nowMs).text) + .font(.title3.weight(.semibold)) + .fixedSize(horizontal: false, vertical: true) + if let problem = plan.problem { Problem(problem) } + + modes + + if let prices = home.prices.prices { + Card { + PriceChart(prices: prices, nowMs: site.nowMs) + if home.prices.hasHole { + Hint("Some hours are missing their price.") + } else if prices.stale { + Hint("Tomorrow's rates aren't published yet.") + } + } + } + + timeline + } + .onAppear { + plan.activate() + home.prices.activate() + } + .onDisappear { + plan.deactivate() + home.prices.deactivate() + } + } + + // MARK: How the home is run + + @ViewBuilder private var modes: some View { + Kicker("How your home is run") + if plan.inManual, let planHome = plan.planHome { + Card { + Text("The plan is not running the battery.") + if plan.canControl { + Button(sendingMode == planHome.key ? "Sending…" : "Use the plan") { choose(planHome.key) } + .buttonStyle(.primary) + .disabled(isSending) + } + } + } + ForEach(plan.primaryModes) { choice($0) } + if !plan.advancedModes.isEmpty { + if showAdvanced { + ForEach(plan.advancedModes) { choice($0) } + Button("Fewer options") { showAdvanced = false }.buttonStyle(.quiet) + } else { + if let selected = plan.advancedModes.first(where: { $0.key == plan.shownMode }) { + choice(selected) + } + Button("More ways to run it") { showAdvanced = true }.buttonStyle(.quiet) + } + } + status + } + + private var isSending: Bool { + if case .sending = plan.command { return true } + return false + } + + private var sendingMode: String? { + if case .sending(let m) = plan.command { return m } + return nil + } + + private func choose(_ mode: String) { + Task { await plan.setMode(mode) } + if plan.advancedModes.contains(where: { $0.key == mode }) { showAdvanced = false } + } + + private func choice(_ info: ModeInfo) -> some View { + let pressed = plan.shownMode == info.key + return Button { choose(info.key) } label: { + VStack(alignment: .leading, spacing: 4) { + HStack { + Text(PlanText.label(info)).font(.body.weight(.semibold)).foregroundStyle(Theme.fg) + Spacer() + if sendingMode == info.key { + Text("Sending…").font(.caption).foregroundStyle(Theme.fgDim) + } else if pressed { + Text("In use").font(.caption.weight(.semibold)).foregroundStyle(Theme.accent) + } + } + Text(PlanText.help(info)) + .font(.footnote) + .foregroundStyle(Theme.fgDim) + .multilineTextAlignment(.leading) + .fixedSize(horizontal: false, vertical: true) + } + .padding(14) + .frame(maxWidth: .infinity, alignment: .leading) + .background(Theme.surfaceRaised, in: RoundedRectangle(cornerRadius: Theme.radius)) + .overlay(RoundedRectangle(cornerRadius: Theme.radius).strokeBorder(pressed ? Theme.accent : Theme.line, lineWidth: pressed ? 2 : 1)) + } + .buttonStyle(.plain) + .disabled(!plan.canControl || isSending) + .accessibilityAddTraits(pressed ? .isSelected : []) + } + + /// One line, in the band's voice. Never a modal. + @ViewBuilder private var status: some View { + switch plan.command { + case .sending: + Hint("Sending…") + case .applied: + Hint("Done.", tone: Theme.exporting) + case .unconfirmed: + Hint("Your box took it, but hasn't confirmed yet. It'll show here when it does.", tone: Theme.generation) + case .failed(let help): + Hint(help, tone: Theme.generation) + case .idle: + switch plan.whyNoControl { + case .role: Hint("You have view-only access, so this is the owner's to change.") + case .box: Hint("This box doesn't support changing how it runs.") + case nil: EmptyView() + } + } + } + + // MARK: The next twelve hours + + @ViewBuilder private var timeline: some View { + let now = site.nowMs + let slots = Array((plan.plan?.slots ?? []).filter { $0.startMs + $0.durationMs > now }.prefix(48)) + if !slots.isEmpty { + let peak = max(1, slots.map { abs($0.batteryW) }.max() ?? 1) + let currency = home.prices.currency + HStack { + Kicker("Next 12 hours") + Spacer() + Text("to import, \(PriceUnits.unit(currency).perKwh)").font(.caption).foregroundStyle(Theme.fgMuted) + } + VStack(spacing: 0) { + ForEach(slots) { slot in + SlotRow(slot: slot, peakW: peak, isNow: now >= slot.startMs && now < slot.startMs + slot.durationMs, currency: currency) + } + } + } else if plan.loading { + Hint("Asking your box…") + } + } +} + +private struct SlotRow: View { + let slot: PlanSlot + let peakW: Double + let isNow: Bool + let currency: String + + var body: some View { + let action = PlanText.action(slot) + let parts = PowerFormat.parts(slot.batteryW) + HStack(spacing: 10) { + Text(Clock.time(slot.startMs)) + .font(Theme.number(13, weight: isNow ? .bold : .regular)) + .frame(width: 52, alignment: .leading) + GeometryReader { geo in + Capsule() + .fill(action == .charge ? Theme.storage : action == .discharge ? Theme.accent : Theme.fgMuted) + .frame(width: max(2, geo.size.width * abs(slot.batteryW) / peakW)) + .frame(maxHeight: .infinity) + } + .frame(width: 60, height: 6) + Group { + if action == .idle { + Text("resting").foregroundStyle(Theme.fgDim) + } else { + Text(parts.text).font(Theme.number(13)) + Text(" \(parts.unit) \(action == .charge ? "in" : "out")").foregroundStyle(Theme.fgDim) + } + } + .font(.footnote) + .frame(width: 90, alignment: .leading) + Text(PlanText.reason(slot.reason)) + .font(.footnote) + .foregroundStyle(Theme.fgDim) + .lineLimit(1) + Spacer(minLength: 4) + if let price = PriceUnits.text(slot.priceMinor, currency) { + Text(price).font(Theme.number(12, weight: .regular)).foregroundStyle(Theme.fgDim) + } + } + .padding(.vertical, 7) + .padding(.horizontal, 8) + .background(isNow ? Theme.surfaceRaised : Color.clear, in: RoundedRectangle(cornerRadius: 8)) + .accessibilityElement(children: .combine) + } +} diff --git a/appleApp/FTW/UI/PriceChart.swift b/appleApp/FTW/UI/PriceChart.swift new file mode 100644 index 0000000..1c69a01 --- /dev/null +++ b/appleApp/FTW/UI/PriceChart.swift @@ -0,0 +1,125 @@ +import Charts +import FTWKit +import SwiftUI + +/// Prices ahead, as bars from zero. Compact on Now: the price this moment +/// and the cheapest two hours. Full on Plan: every slot of today and +/// tomorrow with the moment marked. +struct PriceChart: View { + let prices: Prices + let nowMs: Double + var compact = false + + var body: some View { + let unit = PriceUnits.unit(prices.currency) + let summary = PriceStrip.summary(prices, nowMs: nowMs) + VStack(alignment: .leading, spacing: 10) { + if compact { + VStack(alignment: .leading, spacing: 2) { + Kicker("Market now") + Text("Electricity price").font(.headline) + } + HStack(alignment: .firstTextBaseline) { + VStack(alignment: .leading, spacing: 2) { + (Text(PriceStrip.text(summary.current?.totalMinor, prices.currency)).font(Theme.number(28, weight: .bold)) + Text(" \(unit.perKwh)").font(.caption)) + Text("\(prices.zone.isEmpty ? "—" : prices.zone) · incl. fees and VAT").font(.caption).foregroundStyle(Theme.fgMuted) + } + Spacer() + VStack(alignment: .trailing, spacing: 2) { + Text("Cheapest 2 h").font(.caption).foregroundStyle(Theme.fgDim) + if let block = summary.cheapest { + Text("\(PriceStrip.text(block.meanMinor, prices.currency)) \(unit.label)").font(Theme.number(15)).foregroundStyle(Theme.exporting) + Text("\(Clock.time(block.startMs))–\(Clock.time(block.endMs))").font(.caption).foregroundStyle(Theme.fgDim) + } else { + Text("No 2 h window published").font(.caption).foregroundStyle(Theme.fgDim) + } + } + } + .accessibilityElement(children: .combine) + if summary.bars.isEmpty { + Hint("No prices published ahead yet.") + } else { + strip(summary, unit: unit) + .frame(height: 64) + if let mean = summary.meanMinor { + Hint("Dotted line: average ahead, \(PriceStrip.text(mean, prices.currency)) \(unit.label)", tone: Theme.fgMuted) + } + } + } else { + HStack { + Text("Price to import").font(.headline) + Spacer() + Text(unit.perKwh).font(.caption).foregroundStyle(Theme.fgMuted) + } + full(unit: unit) + .frame(height: 180) + } + } + } + + private func strip(_ summary: PriceStrip.Summary, unit: PriceUnits.Unit) -> some View { + Chart { + ForEach(summary.bars) { bar in + BarMark( + xStart: .value("From", date(bar.startMs)), + xEnd: .value("To", date(bar.endMs)), + y: .value("Price", PriceUnits.display(bar.minor, prices.currency)) + ) + .foregroundStyle(color(bar.tone)) + .opacity(bar.current ? 1 : 0.8) + } + if let mean = summary.meanMinor { + RuleMark(y: .value("Average", PriceUnits.display(mean, prices.currency))) + .lineStyle(StrokeStyle(lineWidth: 1, dash: [2, 3])) + .foregroundStyle(Theme.fgMuted) + } + } + .chartXAxis(.hidden) + .chartYAxis(.hidden) + .accessibilityLabel("Upcoming prices as bars from zero. The dotted line marks the average ahead.") + } + + private func full(unit: PriceUnits.Unit) -> some View { + Chart { + ForEach(prices.slots) { slot in + let past = slot.startMs + slot.durationMs <= nowMs + let current = nowMs >= slot.startMs && nowMs < slot.startMs + slot.durationMs + BarMark( + xStart: .value("From", date(slot.startMs)), + xEnd: .value("To", date(slot.startMs + slot.durationMs)), + y: .value("Price", PriceUnits.display(slot.totalMinor, prices.currency)) + ) + .foregroundStyle(current ? Theme.accent : past ? Theme.fgMuted.opacity(0.35) : Theme.storage) + } + RuleMark(x: .value("Now", date(nowMs))) + .foregroundStyle(Theme.accent) + .lineStyle(StrokeStyle(lineWidth: 1)) + .annotation(position: .top, alignment: .leading) { + Text("now").font(Theme.number(10)).foregroundStyle(Theme.accent) + } + } + .chartXAxis { + AxisMarks(values: .stride(by: .hour, count: 6)) { _ in + AxisGridLine().foregroundStyle(Theme.line) + AxisValueLabel(format: .dateTime.hour()) + } + } + .chartYAxis { + AxisMarks(position: .leading) { _ in + AxisGridLine().foregroundStyle(Theme.line) + AxisValueLabel() + } + } + } + + private func date(_ ms: Double) -> Date { Date(timeIntervalSince1970: ms / 1000) } + + private func color(_ tone: PriceStrip.Tone) -> Color { + switch tone { + case .dear: return Theme.importing + case .cheap: return Theme.exporting + case .flat: return Theme.fgMuted + case .negative: return Theme.generation + } + } +} diff --git a/appleApp/FTW/UI/RootView.swift b/appleApp/FTW/UI/RootView.swift new file mode 100644 index 0000000..bfda663 --- /dev/null +++ b/appleApp/FTW/UI/RootView.swift @@ -0,0 +1,213 @@ +import FTWKit +import SwiftUI + +/// The shell: pairing when there is nothing to show, else one home in four +/// screens. Paints from what is on the phone and never waits on the network. +struct RootView: View { + let app: AppModel + + var body: some View { + Group { + if app.needsPairing { + // A new link is a new offer, and gets a fresh screen. + PairView(app: app) + .id(app.offeredLink ?? "") + } else if let home = app.home { + HomeView(app: app, home: home) + } + } + .background(Theme.surface.ignoresSafeArea()) + .tint(Theme.accent) + } +} + +enum HomeTab: String, Hashable { + case now, plan, history, box + + /// Where a home opens. Debug builds take `-FTWTab plan` and the like, so + /// CI can photograph every screen of the demo. + static var initial: HomeTab { + #if DEBUG + return UserDefaults.standard.string(forKey: "FTWTab").flatMap(HomeTab.init(rawValue:)) ?? .now + #else + return .now + #endif + } +} + +/// Which charger's sheet is open. Nil id means every charger. +struct ChargerRequest: Identifiable, Equatable { + let loadpointID: String? + var id: String { loadpointID ?? "*" } +} + +struct HomeView: View { + let app: AppModel + let home: HomeModels + @State private var tab: HomeTab = .initial + @State private var charger: ChargerRequest? + + var body: some View { + VStack(spacing: 0) { + if app.isDemo { + DemoBand(site: home.site) { app.exitDemo() } + } else { + FreshnessBandView(band: home.site.freshness(noCarrier: app.connectHelp != nil), frameAtMs: home.site.lastFrameAtMs) + } + ChargingNotice(site: home.site, charging: home.charging) { id in + tab = .now + charger = ChargerRequest(loadpointID: id) + } + TabView(selection: $tab) { + Tab("Now", systemImage: "house", value: HomeTab.now) { + Screen(app: app) { + NowView(app: app, home: home, openCharger: { charger = ChargerRequest(loadpointID: $0) }, open: { tab = $0 }) + } + } + Tab("Plan", systemImage: "calendar", value: HomeTab.plan) { + Screen(app: app) { PlanView(home: home) } + } + Tab("History", systemImage: "chart.xyaxis.line", value: HomeTab.history) { + Screen(app: app) { HistoryView(home: home) } + } + Tab("Box", systemImage: "shippingbox", value: HomeTab.box) { + Screen(app: app) { + if app.isDemo { + DemoBoxView(site: home.site) { app.exitDemo() } + } else { + BoxView(app: app, home: home) + } + } + } + } + .tabViewStyle(.sidebarAdaptable) + } + .sheet(item: $charger) { request in + EVSheet(site: home.site, model: home.loadpoints, loadpointID: request.loadpointID) + } + .onAppear { home.charging.activate() } + .onDisappear { home.charging.deactivate() } + } +} + +/// One tab's scrolling page, with pull to refresh. +private struct Screen: View { + let app: AppModel + @ViewBuilder var content: Content + + var body: some View { + ScrollView { + VStack(alignment: .leading, spacing: 16) { content } + .padding(16) + .frame(maxWidth: 640, alignment: .leading) + .frame(maxWidth: .infinity) + } + .scrollDismissesKeyboard(.interactively) + .background(Theme.surface) + .refreshable { await app.refresh() } + } +} + +/// The one place freshness is said. Above every screen, never scrolled away. +struct FreshnessBandView: View { + let band: Freshness.Band + let frameAtMs: Double? + + var body: some View { + HStack(spacing: 8) { + Circle() + .fill(color) + .frame(width: 8, height: 8) + .phaseAnimator([false, true], trigger: frameAtMs) { dot, beat in + dot.scaleEffect(band.tone == .live && beat ? 1.5 : 1) + } animation: { _ in .easeOut(duration: 0.3) } + Text(band.message) + .font(.footnote.weight(.medium)) + .foregroundStyle(Theme.fg) + .lineLimit(1) + .minimumScaleFactor(0.8) + Spacer(minLength: 4) + if let wait = band.wait { + Text(wait) + .font(Theme.number(12, weight: .regular)) + .foregroundStyle(Theme.fgMuted) + .accessibilityHidden(true) + } + if let age = band.age { + Text(age) + .font(Theme.number(12, weight: .regular)) + .foregroundStyle(age == "—" ? Theme.fgMuted : Theme.fgDim) + } + } + .padding(.horizontal, 16) + .padding(.vertical, 8) + .background(Theme.surfaceSunken.ignoresSafeArea(edges: .top)) + .overlay(alignment: .bottom) { Theme.line.frame(height: 1) } + .accessibilityElement(children: .combine) + } + + private var color: Color { + switch band.tone { + case .live: return Theme.freshLive + case .stale: return Theme.freshStale + case .reaching, .lost: return Theme.freshLost + } + } +} + +/// The demo says it is a demo, everywhere, and offers the way out. +struct DemoBand: View { + let site: SiteModel + let exit: () -> Void + + var body: some View { + HStack(spacing: 8) { + Circle() + .fill(site.session.phase == .streaming ? Theme.accent : Theme.fgMuted) + .frame(width: 8, height: 8) + Text(site.session.phase == .streaming ? "Live demo · simulated home" : "Starting the demo") + .font(.footnote.weight(.medium)) + Spacer() + Button("Exit demo", action: exit) + .font(.footnote.weight(.semibold)) + .buttonStyle(.quiet) + } + .padding(.horizontal, 16) + .padding(.vertical, 6) + .background(Theme.surfaceSunken.ignoresSafeArea(edges: .top)) + .overlay(alignment: .bottom) { Theme.line.frame(height: 1) } + } +} + +/// A car on the cable, said once above every screen, with a way into its +/// sheet. Only what the box said, and dated when it is out of date. +struct ChargingNotice: View { + let site: SiteModel + let charging: ChargingWatch + let open: (String) -> Void + + var body: some View { + let fresh = charging.fresh && site.session.phase == .streaming + ForEach(site.heardFromBox ? charging.points.filter(\.pluggedIn) : []) { lp in + let current = fresh && lp.charger?.available != false + VStack(alignment: .leading, spacing: 4) { + Text(current ? "Car connected" : "Car status is out of date") + .font(.subheadline.weight(.semibold)) + Hint(current ? EVText.status(lp, canControl: site.canConfigure) : "Waiting for current charger status. The last reading cannot confirm charging.", tone: Theme.fg) + if current, let plan = EVText.plan(lp, nowMs: site.nowMs, canControl: site.canConfigure) { + Hint(plan) + } + if lp.manualSaveError { Hint(EVText.manualSaveErrorText) } + Button("Check charging\(lp.socSource != "vehicle" ? " and battery level" : "")") { open(lp.id) } + .buttonStyle(.quiet) + .font(.footnote.weight(.semibold)) + } + .padding(.horizontal, 16) + .padding(.vertical, 10) + .frame(maxWidth: .infinity, alignment: .leading) + .background(Theme.surfaceRaised) + .overlay(alignment: .leading) { Theme.mobility.frame(width: 3) } + .overlay(alignment: .bottom) { Theme.line.frame(height: 1) } + } + } +} diff --git a/appleApp/FTW/UI/Theme.swift b/appleApp/FTW/UI/Theme.swift new file mode 100644 index 0000000..fea21ef --- /dev/null +++ b/appleApp/FTW/UI/Theme.swift @@ -0,0 +1,207 @@ +import FTWKit +import SwiftUI + +/// The web app's design roles (src/styles/tokens.css), light and dark, so the +/// native app and the box's own page look like one product. Views read +/// roles, never raw colours. +enum Theme { + static let surface = pair(0xF4F4F2, 0x0D0D0D) + static let surfaceSunken = pair(0xECECE8, 0x101010) + static let surfaceRaised = pair(0xFAFAF8, 0x161616) + static let surfaceElevated = pair(0xFFFFFF, 0x1E1E1E) + static let line = pair(0xCECEC7, 0x2A2A2A) + static let fg = pair(0x191919, 0xE8E8E8) + static let fgDim = pair(0x4F4F4B, 0xA0A0A0) + static let fgMuted = pair(0x686862, 0x858585) + static let onAccent = pair(0x0A0A0A, 0x0A0A0A) + + static let accent = pair(0xDA7F00, 0xFFAC41) + static let importing = pair(0xCC243D, 0xFF6E74) + static let exporting = pair(0x009639, 0x5FD37F) + static let generation = pair(0xD48500, 0xFFB000) + static let storage = pair(0x008FA8, 0x13DCF6) + static let mobility = pair(0x7F5BB6, 0xCCA8FF) + + static let freshLive = exporting + static let freshStale = generation + static let freshLost = fgMuted + + static let radius: CGFloat = 14 + static let radiusSmall: CGFloat = 10 + + static func color(_ tone: Flow.Tone) -> Color { + switch tone { + case .muted: return fgMuted + case .importing: return importing + case .exporting: return exporting + case .solar: return generation + case .battery, .charging, .discharging: return storage + case .ev: return mobility + case .house: return accent + } + } + + /// Mono figures, as the web app sets every number, so a value that + /// changes every second does not shift the line under the reader's eye. + static func number(_ size: CGFloat, weight: Font.Weight = .semibold) -> Font { + .system(size: size, weight: weight, design: .monospaced) + } + + private static func pair(_ light: UInt32, _ dark: UInt32) -> Color { + #if os(iOS) + return Color(UIColor { $0.userInterfaceStyle == .dark ? UIColor(rgb: dark) : UIColor(rgb: light) }) + #else + return Color(NSColor(name: nil) { appearance in + appearance.bestMatch(from: [.darkAqua, .aqua]) == .darkAqua ? NSColor(rgb: dark) : NSColor(rgb: light) + }) + #endif + } +} + +#if os(iOS) +private extension UIColor { + convenience init(rgb: UInt32) { + self.init(red: CGFloat((rgb >> 16) & 0xFF) / 255, green: CGFloat((rgb >> 8) & 0xFF) / 255, blue: CGFloat(rgb & 0xFF) / 255, alpha: 1) + } +} +#else +private extension NSColor { + convenience init(rgb: UInt32) { + self.init(srgbRed: CGFloat((rgb >> 16) & 0xFF) / 255, green: CGFloat((rgb >> 8) & 0xFF) / 255, blue: CGFloat(rgb & 0xFF) / 255, alpha: 1) + } +} +#endif + +/// A raised card, the web app's `.card`. +struct Card: View { + @ViewBuilder var content: Content + + var body: some View { + VStack(alignment: .leading, spacing: 8) { content } + .padding(.vertical, 14) + .padding(.horizontal, 16) + .frame(maxWidth: .infinity, alignment: .leading) + .background(Theme.surfaceRaised, in: RoundedRectangle(cornerRadius: Theme.radius)) + .overlay(RoundedRectangle(cornerRadius: Theme.radius).strokeBorder(Theme.line, lineWidth: 1)) + } +} + +/// The small caps line over a card title. +struct Kicker: View { + let text: String + init(_ text: String) { self.text = text } + + var body: some View { + Text(text.uppercased()) + .font(.caption2.weight(.semibold)) + .tracking(0.8) + .foregroundStyle(Theme.fgMuted) + } +} + +/// A sentence under a control: what happened, or what to do. +struct Hint: View { + let text: String + var tone: Color = Theme.fgDim + init(_ text: String, tone: Color = Theme.fgDim) { + self.text = text + self.tone = tone + } + + var body: some View { + Text(text) + .font(.footnote) + .foregroundStyle(tone) + .fixedSize(horizontal: false, vertical: true) + } +} + +/// The web app's three button weights. +struct FTWButtonStyle: ButtonStyle { + enum Kind { case primary, quiet, outline, danger } + var kind: Kind + @Environment(\.isEnabled) private var enabled + + func makeBody(configuration: Configuration) -> some View { + configuration.label + .font(.body.weight(kind == .quiet ? .regular : .semibold)) + .padding(.vertical, kind == .quiet ? 6 : 11) + .padding(.horizontal, kind == .quiet ? 4 : 16) + .frame(maxWidth: kind == .primary || kind == .danger ? .infinity : nil) + .foregroundStyle(foreground) + .background(background, in: RoundedRectangle(cornerRadius: Theme.radiusSmall)) + .overlay { + if kind == .outline { + RoundedRectangle(cornerRadius: Theme.radiusSmall).strokeBorder(Theme.line, lineWidth: 1) + } + } + .opacity(enabled ? (configuration.isPressed ? 0.7 : 1) : 0.45) + .contentShape(Rectangle()) + } + + private var foreground: Color { + switch kind { + case .primary: return Theme.onAccent + case .danger: return .white + case .quiet: return Theme.accent + case .outline: return Theme.fg + } + } + + private var background: Color { + switch kind { + case .primary: return Theme.accent + case .danger: return Theme.importing + case .quiet, .outline: return .clear + } + } +} + +extension ButtonStyle where Self == FTWButtonStyle { + static var primary: FTWButtonStyle { FTWButtonStyle(kind: .primary) } + static var quiet: FTWButtonStyle { FTWButtonStyle(kind: .quiet) } + static var outline: FTWButtonStyle { FTWButtonStyle(kind: .outline) } + static var danger: FTWButtonStyle { FTWButtonStyle(kind: .danger) } +} + +/// A pressed-button choice between a few options, the web app's range picker. +struct Segments: View { + let options: [T] + let selected: T + let label: (T) -> String + let choose: (T) -> Void + + var body: some View { + HStack(spacing: 4) { + ForEach(options, id: \.self) { option in + Button { choose(option) } label: { + Text(label(option)) + .font(.footnote.weight(.semibold)) + .padding(.vertical, 5) + .padding(.horizontal, 10) + .foregroundStyle(option == selected ? Theme.onAccent : Theme.fgDim) + .background(option == selected ? Theme.accent : Color.clear, in: Capsule()) + } + .buttonStyle(.plain) + .accessibilityAddTraits(option == selected ? .isSelected : []) + } + } + .padding(3) + .background(Theme.surfaceSunken, in: Capsule()) + } +} + +/// Clock time in the phone's own style. +enum Clock { + static func time(_ ms: Double) -> String { + Date(timeIntervalSince1970: ms / 1000).formatted(date: .omitted, time: .shortened) + } + + static func dayAndTime(_ ms: Double) -> String { + Date(timeIntervalSince1970: ms / 1000).formatted(.dateTime.day().month(.abbreviated).hour().minute()) + } + + static func day(_ ms: Double) -> String { + Date(timeIntervalSince1970: ms / 1000).formatted(date: .long, time: .omitted) + } +} diff --git a/appleApp/FTWKit/Package.resolved b/appleApp/FTWKit/Package.resolved new file mode 100644 index 0000000..ed2adca --- /dev/null +++ b/appleApp/FTWKit/Package.resolved @@ -0,0 +1,24 @@ +{ + "originHash" : "887bf9d08cf6d2c6849bb790dc2fb6ad11cc2f85e25edea3cd00de3a4912a469", + "pins" : [ + { + "identity" : "swift-asn1", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-asn1.git", + "state" : { + "revision" : "3b6410f7dee09eb33cdd26260c5fd47fda19b0e2", + "version" : "1.7.3" + } + }, + { + "identity" : "swift-crypto", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-crypto.git", + "state" : { + "revision" : "da9d28d69ebe3894b18376c8f2395c2f37b8448f", + "version" : "4.5.2" + } + } + ], + "version" : 3 +} diff --git a/appleApp/FTWKit/Package.swift b/appleApp/FTWKit/Package.swift new file mode 100644 index 0000000..f28fe2d --- /dev/null +++ b/appleApp/FTWKit/Package.swift @@ -0,0 +1,38 @@ +// swift-tools-version:6.0 +// +// FTWKit: everything the native app does that is not a pixel. Pairing, the +// passkey derivations, Noise IK, frames, the relay, the session and the +// state each screen reads. The SwiftUI app in ../FTW is a thin layer over it. +// +// It builds and tests on Linux as well as on Apple platforms, so the +// protocol can be checked anywhere Swift runs. CryptoKit provides the +// primitives on Apple platforms; swift-crypto provides the same API on +// Linux and is linked nowhere else. + +import PackageDescription + +let package = Package( + name: "FTWKit", + platforms: [.iOS(.v18), .macOS(.v15)], + products: [ + .library(name: "FTWKit", targets: ["FTWKit"]), + ], + dependencies: [ + // Below 5, whose manifest needs Swift 6.2: Xcode 16 must still + // resolve this package even though it links it nowhere. + .package(url: "https://github.com/apple/swift-crypto.git", "3.0.0"..<"5.0.0"), + ], + targets: [ + .target( + name: "FTWKit", + dependencies: [ + .product(name: "Crypto", package: "swift-crypto", condition: .when(platforms: [.linux])), + ] + ), + .testTarget( + name: "FTWKitTests", + dependencies: ["FTWKit"], + resources: [.copy("Resources")] + ), + ] +) diff --git a/appleApp/FTWKit/Sources/FTWKit/Carrier/Carrier.swift b/appleApp/FTWKit/Sources/FTWKit/Carrier/Carrier.swift new file mode 100644 index 0000000..9d7e04e --- /dev/null +++ b/appleApp/FTWKit/Sources/FTWKit/Carrier/Carrier.swift @@ -0,0 +1,64 @@ +import Foundation + +/// How frames are reaching us. Orthogonal to `SourceState`, never merged +/// with it: "connected, but the inverter went quiet 40 seconds ago" needs +/// both. +public enum CarrierKind: String, Sendable, Codable { + case webrtc, relay, cache, none +} + +public enum CarrierStatus: Equatable, Sendable { + case connecting + case open(sinceMs: Double) + case closed(reason: String, retryable: Bool) + + public var isOpen: Bool { + if case .open = self { return true } + return false + } + + var phaseName: String { + switch self { + case .connecting: return "connecting" + case .open: return "open" + case .closed: return "closed" + } + } +} + +/// Moves opaque frames and nothing else. It cannot read a frame and has no +/// opinion about the protocol, so a LAN path can be added later as one more +/// implementation without a line changing above this. +@MainActor +public protocol Carrier: AnyObject { + var kind: CarrierKind { get } + var status: CarrierStatus { get } + func send(_ frame: Bytes) + /// Replaces the handlers. One owner at a time: the carrier stack is + /// built as a chain, each layer owning the one below it. + func setHandlers(onFrame: @escaping @MainActor (Bytes) -> Void, onStatus: @escaping @MainActor (CarrierStatus) -> Void) + /// Recheck the live path after the app or network wakes. Frames are + /// never replayed. + func wake() + func close(reason: String) +} + +// MARK: - WebSockets + +/// The socket underneath the relay carrier, reduced to what it uses. +@MainActor +public protocol WebSocketConnection: AnyObject { + func send(_ data: Bytes) + func close() +} + +@MainActor +public struct WebSocketEvents { + public var onOpen: @MainActor () -> Void + public var onText: @MainActor (String) -> Void + public var onBinary: @MainActor (Bytes) -> Void + /// Close code and reason. A socket that failed to open closes with 1006. + public var onClose: @MainActor (Int, String) -> Void +} + +public typealias WebSocketFactory = @MainActor (_ url: URL, _ events: WebSocketEvents) -> WebSocketConnection diff --git a/appleApp/FTWKit/Sources/FTWKit/Carrier/NoiseCarrier.swift b/appleApp/FTWKit/Sources/FTWKit/Carrier/NoiseCarrier.swift new file mode 100644 index 0000000..53f671c --- /dev/null +++ b/appleApp/FTWKit/Sources/FTWKit/Carrier/NoiseCarrier.swift @@ -0,0 +1,233 @@ +import Foundation + +/// The carrier that makes every other carrier private. +/// +/// Wraps an inner carrier and runs the Noise IK handshake across it before a +/// single application frame moves. Above, an ordinary carrier; below, opaque +/// bytes. That is the whole reason the relay can be blind without the +/// session knowing a relay exists. +/// +/// Nothing here is on the first-frame path: the handshake is two round +/// trips and runs behind cached readings already on screen. +@MainActor +public final class NoiseCarrier: Carrier { + /// The box answers a refused handshake with silence, on purpose, so this + /// is the only thing that tells "not yet" from "never". + static let handshakeDeadlineMs: Double = 12_000 + /// A foreground person should never wait out the ordinary window. + static let foregroundHandshakeDeadlineMs: Double = 3_000 + static let handshakeBackoffBaseMs: Double = 3_000 + static let handshakeBackoffCapMs: Double = 60_000 + /// Message 2 of IK: an ephemeral key and one tag over an empty payload. + static let message2Bytes = Noise.dhBytes + Noise.tagBytes + /// Failures an inbound frame that is not ours can cause. The relay + /// broadcasts a box frame to every phone in its room, so these are + /// routine routing misses, not errors. + static let foreignFrameErrors: Set = ["E_NOISE_AUTH", "E_NOISE_REPLAY", "E_NOISE_MESSAGE", "E_NOISE_NONCE_EXHAUSTED"] + + private let inner: Carrier + private let staticKey: Primitives.KeyPair + private let remoteStatic: Bytes + private let prologue: Bytes + private let payload: Bytes + private let scheduler: Scheduler + + private var handshake: HandshakeState? + private var transport: NoiseTransport? + public private(set) var status: CarrierStatus = .connecting + private var closed = false + private var awaitingReply = false + private var deadline: Cancellable? + private var retry: Cancellable? + private var attempt = 0 + private var foregroundAttempt = false + + private var onFrame: @MainActor (Bytes) -> Void = { _ in } + private var onStatus: @MainActor (CarrierStatus) -> Void = { _ in } + + /// `handshakePayload` is the single-use pairing code, sent encrypted in + /// message 1. The box spends it once and remembers the key it came with. + public init(inner: Carrier, staticKey: Primitives.KeyPair, remoteStatic: Bytes, prologue: Bytes, handshakePayload: Bytes = [], scheduler: Scheduler) { + self.inner = inner + self.staticKey = staticKey + self.remoteStatic = remoteStatic + self.prologue = prologue + self.payload = handshakePayload + self.scheduler = scheduler + inner.setHandlers( + onFrame: { [weak self] bytes in self?.onInnerFrame(bytes) }, + onStatus: { [weak self] status in self?.onInnerStatus(status) } + ) + if inner.status.isOpen { beginHandshake() } + } + + /// Binds a session to its box, so a captured handshake cannot be + /// replayed into another one. + public static func prologue(boxStaticKey: Bytes) -> Bytes { + Array("ftw.session.v1:".utf8) + boxStaticKey + } + + public var kind: CarrierKind { inner.kind } + + public func setHandlers(onFrame: @escaping @MainActor (Bytes) -> Void, onStatus: @escaping @MainActor (CarrierStatus) -> Void) { + self.onFrame = onFrame + self.onStatus = onStatus + } + + public func send(_ frame: Bytes) { + // Before the handshake there is no key, and sending in the clear to + // keep a caller happy would be worse than dropping. + guard let transport, status.isOpen else { return } + do { + inner.send(try transport.encrypt(frame)) + } catch { + // Nonce exhaustion or a destroyed cipher: continuing would risk + // reusing a (key, nonce) pair. + fail("encryption failed", retryable: true) + } + } + + /// Abandon a session the OS may have frozen, and start a fresh one. + public func wake() { + if closed { return } + attempt = 0 + foregroundAttempt = true + resetSession() + retry?.cancel() + retry = nil + setStatus(.closed(reason: "reconnecting after wake", retryable: true)) + inner.wake() + if inner.status.isOpen { beginHandshake() } + } + + public func close(reason: String = "closed by client") { + if closed { return } + closed = true + deadline?.cancel() + retry?.cancel() + resetSession() + inner.close(reason: reason) + setStatus(.closed(reason: reason, retryable: false)) + onFrame = { _ in } + onStatus = { _ in } + } + + private func onInnerStatus(_ s: CarrierStatus) { + if closed { return } + if s.isOpen { + attempt = 0 + retry?.cancel() + retry = nil + beginHandshake() + return + } + // A Noise session cannot survive a gap: its keys belong to one + // handshake and its counters to one stream. So a drop restarts. + resetSession() + retry?.cancel() + retry = nil + setStatus(s) + } + + private func beginHandshake() { + if closed || awaitingReply || transport != nil { return } + // A fresh handshake per connection. Reusing one would mint a second + // cipher pair from the same chaining key. + let hs: HandshakeState + do { + hs = try HandshakeState.initiator(staticKey: staticKey, remoteStatic: remoteStatic, prologue: prologue) + } catch { + fail("the box key is not usable", retryable: false) + return + } + handshake = hs + awaitingReply = true + setStatus(.connecting) + + // A refused handshake is answered with silence, so silence needs its + // own ending or a revoked phone waits forever on an open socket. + deadline?.cancel() + let window = foregroundAttempt ? Self.foregroundHandshakeDeadlineMs : Self.handshakeDeadlineMs + foregroundAttempt = false + deadline = scheduler.after(window) { [weak self] in + guard let self, !self.closed, self.awaitingReply else { return } + self.fail("the box did not answer", retryable: true) + } + + do { + inner.send(try hs.writeMessage(payload)) + } catch { + fail("handshake failed", retryable: true) + } + } + + private func onInnerFrame(_ bytes: Bytes) { + if closed { return } + if awaitingReply { + // Only something the right shape is offered to the handshake. A + // second phone in the house starts its handshake into a running + // telemetry stream, and those frames are not message 2. + if bytes.count == Self.message2Bytes { completeHandshake(bytes) } + return + } + guard let transport else { return } + do { + let frame = try transport.decrypt(bytes) + onFrame(frame) + } catch let e as Noise.NoiseError where Self.foreignFrameErrors.contains(e.code) { + return + } catch { + return + } + } + + private func completeHandshake(_ bytes: Bytes) { + guard let hs = handshake else { return } + do { + _ = try hs.readMessage(bytes) + deadline?.cancel() + deadline = nil + transport = NoiseTransport(try hs.split()) + awaitingReply = false + handshake = nil + attempt = 0 + setStatus(.open(sinceMs: scheduler.nowMs)) + } catch { + // On a shared room another phone's frame can match the length by + // coincidence. A frame that does not open is somebody else's; the + // deadline ends a handshake that is truly going nowhere. + } + } + + private func setStatus(_ s: CarrierStatus) { + status = s + onStatus(s) + } + + /// A retryable failure on a socket that still stands is retried from + /// here, because nowhere else will: the inner carrier only re-emits open + /// after a real reconnect. + private func fail(_ reason: String, retryable: Bool) { + if closed { return } + resetSession() + setStatus(.closed(reason: reason, retryable: retryable)) + guard retryable, inner.status.isOpen else { return } + retry?.cancel() + let ceiling = min(Self.handshakeBackoffCapMs, Self.handshakeBackoffBaseMs * pow(2, Double(attempt))) + attempt = min(attempt + 1, 16) + retry = scheduler.after(scheduler.random() * ceiling) { [weak self] in + guard let self else { return } + self.retry = nil + self.beginHandshake() + } + } + + private func resetSession() { + deadline?.cancel() + deadline = nil + transport?.close() + transport = nil + handshake = nil + awaitingReply = false + } +} diff --git a/appleApp/FTWKit/Sources/FTWKit/Carrier/RelayCarrier.swift b/appleApp/FTWKit/Sources/FTWKit/Carrier/RelayCarrier.swift new file mode 100644 index 0000000..3ad1336 --- /dev/null +++ b/appleApp/FTWKit/Sources/FTWKit/Carrier/RelayCarrier.swift @@ -0,0 +1,235 @@ +import Foundation + +/// A WebSocket to Sourceful's blind relay, wrapped so nothing above it ever +/// learns the network went away. There is no reconnect button in this app +/// and nowhere to put one: a lost connection is this file's problem, and +/// the person sees only the freshness stamp slipping while it is solved. +/// +/// Frames are never queued across a reconnect: an old instruction arriving +/// as if new is what `notValidAfterMs` exists to prevent, so sending on a +/// carrier that is not open drops the frame. The session re-handshakes and +/// asks again. +/// +/// The socket outlives the peer. When the box drops off the relay the socket +/// stays up and the carrier reports closed, then open again the moment the +/// relay says the box is back: an hour offline costs one connection. +@MainActor +public final class RelayCarrier: Carrier { + public static let closeBadJoin = 4400 + public static let closeEpoch = 4409 + public static let closeRotated = 4410 + public static let closeBusy = 4429 + static let ctrlReady = "ready" + static let ctrlGone = "gone" + + static let backoffBaseMs: Double = 500 + static let backoffCapMs: Double = 60_000 + /// Rejoin spread after a rotation, so the old handle and the new one are + /// hard to line up by timing alone. + static let rotateJitterMs: Double = 3_000 + static let correctionLimit = 2 + /// How far the relay may move our epoch. One hour covers a drifting + /// clock; it does not cover a relay steering us onto handles it chose. + static let maxEpochCorrection: Int64 = 1 + + public let kind: CarrierKind = .relay + public private(set) var status: CarrierStatus = .connecting + + private let url: URL + private let secret: Bytes + private let scheduler: Scheduler + private let makeSocket: WebSocketFactory + + private var socket: WebSocketConnection? + private var socketGeneration = 0 + private var dialledAtMs: Double = 0 + private var attempt = 0 + private var corrections = 0 + /// Epochs between the relay's clock and ours, learned from a correction. + private var epochOffset: Int64 = 0 + private var retry: Cancellable? + private var shutdown = false + public private(set) var rttMs: Double? + + private var onFrame: @MainActor (Bytes) -> Void = { _ in } + private var onStatus: @MainActor (CarrierStatus) -> Void = { _ in } + + public init(url: URL = Origin.relayURL, secret: Bytes, scheduler: Scheduler, makeSocket: @escaping WebSocketFactory = URLSessionWebSocket.factory()) { + self.url = url + self.secret = secret + self.scheduler = scheduler + self.makeSocket = makeSocket + dial() + } + + public func setHandlers(onFrame: @escaping @MainActor (Bytes) -> Void, onStatus: @escaping @MainActor (CarrierStatus) -> Void) { + self.onFrame = onFrame + self.onStatus = onStatus + } + + public func send(_ frame: Bytes) { + guard status.isOpen, let socket else { return } + socket.send(frame) + } + + /// Drop an apparently live socket and dial now, after a foreground wake. + public func wake() { + if shutdown { return } + // A dial started within the last second is kept: the app's own wake + // and the network's can land together. + if status == .connecting, socket != nil, scheduler.nowMs - dialledAtMs < 1_000 { return } + retry?.cancel() + retry = nil + attempt = 0 + rttMs = nil + drop() + dial() + } + + /// The network came back: skip whatever backoff is running. + public func networkAvailable() { + guard !shutdown, retry != nil else { return } + retry?.cancel() + retry = nil + attempt = 0 + dial() + } + + public func close(reason: String = "closed by client") { + if shutdown { return } + shutdown = true + retry?.cancel() + retry = nil + drop() + setStatus(.closed(reason: reason, retryable: false)) + onFrame = { _ in } + onStatus = { _ in } + } + + private func dial() { + if shutdown { return } + let epoch = Rendezvous.epoch(nowMs: scheduler.nowMs) + epochOffset + guard let handle = try? Rendezvous.handle(secret: secret, epoch: epoch) else { + setStatus(.closed(reason: "rendezvous secret is missing", retryable: false)) + return + } + setStatus(.connecting) + dialledAtMs = scheduler.nowMs + socketGeneration += 1 + let generation = socketGeneration + let target = url.appendingPathComponent("r").appendingPathComponent(String(epoch)).appendingPathComponent(handle).appendingPathComponent("app") + socket = makeSocket(target, WebSocketEvents( + onOpen: { [weak self] in + guard let self, generation == self.socketGeneration else { return } + self.rttMs = self.scheduler.nowMs - self.dialledAtMs + }, + onText: { [weak self] text in + guard let self, generation == self.socketGeneration else { return } + self.onText(text) + }, + onBinary: { [weak self] bytes in + guard let self, generation == self.socketGeneration else { return } + self.onBinary(bytes) + }, + onClose: { [weak self] code, reason in + guard let self, generation == self.socketGeneration else { return } + self.onClose(code: code, reason: reason) + } + )) + } + + private func onText(_ text: String) { + if text == Self.ctrlReady { + corrections = 0 + setStatus(.open(sinceMs: scheduler.nowMs)) + } else if text == Self.ctrlGone { + // The box left. Keep the socket; the relay says when it is back. + setStatus(.closed(reason: "box offline", retryable: true)) + } + } + + private func onBinary(_ bytes: Bytes) { + guard status.isOpen else { return } + // Only a delivered frame proves the path works, so this is where the + // dial backoff resets. + attempt = 0 + onFrame(bytes) + } + + private func onClose(code: Int, reason: String) { + if shutdown { return } + socket = nil + socketGeneration += 1 + rttMs = nil + + let delayMs: Double + switch code { + case Self.closeRotated: + adoptEpoch(reason) + delayMs = scheduler.random() * Self.rotateJitterMs + case Self.closeEpoch: + adoptEpoch(reason) + corrections += 1 + delayMs = corrections > Self.correctionLimit ? backoff() : 0 + default: + delayMs = backoff() + } + setStatus(.closed(reason: Self.closeReason(code), retryable: true)) + schedule(delayMs) + } + + /// Take the relay's epoch as a clock correction, never as an order. A + /// relay that could name any epoch could make us publish handles of its + /// choosing, so only a strict number within one hour of ours is taken. + private func adoptEpoch(_ announced: String) { + let trimmed = announced.trimmingCharacters(in: .whitespaces) + let digits = trimmed.hasPrefix("-") ? trimmed.dropFirst() : Substring(trimmed) + guard !digits.isEmpty, digits.allSatisfy(\.isASCII), digits.allSatisfy(\.isNumber), let epoch = Int64(trimmed) else { return } + let offset = epoch - Rendezvous.epoch(nowMs: scheduler.nowMs) + guard abs(offset) <= Self.maxEpochCorrection else { return } + epochOffset = offset + } + + /// Full jitter: the whole delay is random, so peers never resynchronise. + private func backoff() -> Double { + let ceiling = min(Self.backoffCapMs, Self.backoffBaseMs * pow(2, Double(attempt))) + attempt = min(attempt + 1, 16) + return scheduler.random() * ceiling + } + + private func schedule(_ delayMs: Double) { + retry?.cancel() + retry = scheduler.after(delayMs) { [weak self] in + guard let self else { return } + self.retry = nil + self.dial() + } + } + + private func drop() { + socketGeneration += 1 + socket?.close() + socket = nil + } + + private func setStatus(_ next: CarrierStatus) { + switch (status, next) { + case (.connecting, .connecting), (.open, .open): + return + case let (.closed(a, _), .closed(b, _)) where a == b: + return + default: + status = next + onStatus(next) + } + } + + static func closeReason(_ code: Int) -> String { + switch code { + case closeEpoch, closeRotated: return "rendezvous rotated" + case closeBusy: return "relay is busy" + case closeBadJoin: return "relay rejected the join" + default: return "connection lost" + } + } +} diff --git a/appleApp/FTWKit/Sources/FTWKit/Carrier/Rendezvous.swift b/appleApp/FTWKit/Sources/FTWKit/Carrier/Rendezvous.swift new file mode 100644 index 0000000..3e628af --- /dev/null +++ b/appleApp/FTWKit/Sources/FTWKit/Carrier/Rendezvous.swift @@ -0,0 +1,28 @@ +import Foundation + +/// Rendezvous handles: the name the box and the app use to find each other +/// on the relay, and the one piece of metadata the relay unavoidably sees. +/// +/// handle = HKDF-SHA256(secret, info = "ftw/rendezvous/v1/")[0..16] +/// +/// The secret arrives optically in the QR and never travels through +/// Sourceful. Without it two epochs' handles are unrelated strings, so the +/// relay cannot follow a household across months from an identifier. +public enum Rendezvous { + /// An hour: short enough not to be a household identifier, long enough + /// that rotations are rare beside ordinary reconnects. + public static let epochMs: Double = 3_600_000 + public static let handleBytes = 16 + + /// The epoch this device's clock guesses. The relay corrects a wrong one. + public static func epoch(nowMs: Double) -> Int64 { + Int64((nowMs / epochMs).rounded(.down)) + } + + public static func handle(secret: Bytes, epoch: Int64) throws -> String { + guard secret.count >= 16 else { + throw Primitives.CryptoFailure(message: "rendezvous secret is too short to be a secret") + } + return Primitives.hkdf(ikm: secret, salt: [], info: Array("ftw/rendezvous/v1/\(epoch)".utf8), length: handleBytes).hex + } +} diff --git a/appleApp/FTWKit/Sources/FTWKit/Carrier/URLSessionWebSocket.swift b/appleApp/FTWKit/Sources/FTWKit/Carrier/URLSessionWebSocket.swift new file mode 100644 index 0000000..ee0769c --- /dev/null +++ b/appleApp/FTWKit/Sources/FTWKit/Carrier/URLSessionWebSocket.swift @@ -0,0 +1,114 @@ +import Foundation +#if canImport(FoundationNetworking) +import FoundationNetworking +#endif + +/// A WebSocket over URLSession, feeding events back on the main actor. +/// +/// Every event carries a generation check through `closed`, so a socket +/// that has been dropped can never deliver a late frame to its successor. +@MainActor +public final class URLSessionWebSocket: NSObject, WebSocketConnection { + private var task: URLSessionWebSocketTask? + private var session: URLSession? + private let events: WebSocketEvents + private var closed = false + private var opened = false + + public static func factory() -> WebSocketFactory { + { url, events in URLSessionWebSocket(url: url, events: events) } + } + + init(url: URL, events: WebSocketEvents) { + self.events = events + super.init() + let delegate = Delegate(owner: self) + let config = URLSessionConfiguration.ephemeral + config.timeoutIntervalForRequest = 30 + let session = URLSession(configuration: config, delegate: delegate, delegateQueue: nil) + self.session = session + let task = session.webSocketTask(with: url) + task.maximumMessageSize = 1 << 20 + self.task = task + task.resume() + receive() + } + + public func send(_ data: Bytes) { + guard !closed, let task else { return } + task.send(.data(Data(data))) { _ in } + } + + public func close() { + guard !closed else { return } + closed = true + task?.cancel(with: .normalClosure, reason: nil) + session?.invalidateAndCancel() + task = nil + session = nil + } + + private func receive() { + guard let task else { return } + task.receive { [weak self] result in + Task { @MainActor [weak self] in + guard let self, !self.closed else { return } + switch result { + case .success(let message): + if !self.opened { self.didOpen() } + switch message { + case .string(let text): self.events.onText(text) + case .data(let data): self.events.onBinary(data.byteArray) + @unknown default: break + } + self.receive() + case .failure: + // The close handshake arrives through the delegate with its + // code and reason, and the relay's codes matter: 4409 and + // 4410 carry the epoch. The delegate may land a moment after + // this failure, so it gets that moment before a plain 1006. + try? await Task.sleep(nanoseconds: 250_000_000) + let code = task.closeCode.rawValue + let reason = task.closeReason.map { String(decoding: $0, as: UTF8.self) } ?? "" + self.didClose(code: code == 0 ? 1006 : code, reason: reason) + } + } + } + } + + fileprivate func didOpen() { + guard !closed, !opened else { return } + opened = true + events.onOpen() + } + + fileprivate func didClose(code: Int, reason: String) { + guard !closed else { return } + closed = true + session?.invalidateAndCancel() + task = nil + session = nil + events.onClose(code, reason) + } + + private final class Delegate: NSObject, URLSessionWebSocketDelegate, @unchecked Sendable { + weak var owner: URLSessionWebSocket? + + init(owner: URLSessionWebSocket) { + self.owner = owner + } + + func urlSession(_ session: URLSession, webSocketTask: URLSessionWebSocketTask, didOpenWithProtocol protocol: String?) { + Task { @MainActor [weak owner] in owner?.didOpen() } + } + + func urlSession(_ session: URLSession, webSocketTask: URLSessionWebSocketTask, didCloseWith closeCode: URLSessionWebSocketTask.CloseCode, reason: Data?) { + let text = reason.map { String(decoding: $0, as: UTF8.self) } ?? "" + Task { @MainActor [weak owner] in owner?.didClose(code: closeCode.rawValue, reason: text) } + } + + func urlSession(_ session: URLSession, task: URLSessionTask, didCompleteWithError error: Error?) { + Task { @MainActor [weak owner] in owner?.didClose(code: 1006, reason: "") } + } + } +} diff --git a/appleApp/FTWKit/Sources/FTWKit/Crypto/Noise.swift b/appleApp/FTWKit/Sources/FTWKit/Crypto/Noise.swift new file mode 100644 index 0000000..08d52e6 --- /dev/null +++ b/appleApp/FTWKit/Sources/FTWKit/Crypto/Noise.swift @@ -0,0 +1,337 @@ +import Foundation + +/// Noise_IK_25519_ChaChaPoly_SHA256. +/// +/// IK because the initiator already knows the responder's static key: the +/// app reads it optically off the box's QR code, so the trust anchor never +/// travels through Sourceful's cloud. A hostile relay can drop frames but +/// cannot present itself as a box. +/// +/// IK: +/// <- s +/// ... +/// -> e, es, s, ss +/// <- e, ee, se +/// +/// Byte-identical to the TypeScript client and the Go box, and checked +/// against the Cacophony vectors and the shared interop vectors in the tests. +public enum Noise { + public static let protocolName = "Noise_IK_25519_ChaChaPoly_SHA256" + public static let dhBytes = 32 + public static let hashBytes = 32 + public static let tagBytes = 16 + /// 2^64 - 1 is reserved by the spec and must never encrypt. + public static let maxNonce = UInt64.max + + public static let message1Overhead = dhBytes + dhBytes + tagBytes + tagBytes + public static let message2Overhead = dhBytes + tagBytes + + /// Failures here are local: they never cross the wire. The carrier maps + /// them to prose; this layer only says which thing went wrong. + public struct NoiseError: Error, Equatable { + public let code: String + public let message: String + } + + /// Noise HKDF: one extract over the chaining key, then expand with empty + /// info. Splitting one expand is byte-identical to the chained HMACs. + static func hkdf2(_ chainingKey: Bytes, _ ikm: Bytes) -> (Bytes, Bytes) { + let prk = Primitives.hmacSHA256(key: chainingKey, ikm) + let t1 = Primitives.hmacSHA256(key: prk, [1]) + let t2 = Primitives.hmacSHA256(key: prk, t1 + [2]) + return (t1, t2) + } + + static func dh(_ secret: Bytes, _ peer: Bytes) throws -> Bytes { + do { + return try Primitives.x25519(secret: secret, peer: peer) + } catch { + throw NoiseError(code: "E_NOISE_DH", message: "X25519 failed") + } + } + + /// ChaChaPoly's 96-bit nonce, Noise-encoded: four zero bytes, then the + /// 64-bit counter little-endian. + static func nonceBytes(_ n: UInt64) -> Bytes { + var out = Bytes(repeating: 0, count: 12) + for i in 0..<8 { out[4 + i] = UInt8(truncatingIfNeeded: n >> (8 * UInt64(i))) } + return out + } +} + +/// One direction's key and counter. +/// +/// The counter never wraps. Reusing a (key, nonce) pair under +/// ChaCha20-Poly1305 hands an observer the XOR of two plaintexts and makes the +/// authenticator forgeable, so exhaustion throws and the session dies. +public final class CipherState { + private var key: Bytes? + public private(set) var nonce: UInt64 = 0 + private var destroyed = false + + public init(key: Bytes? = nil) { + self.key = key + } + + public var hasKey: Bool { key != nil } + + /// Jump the counter, for a carrier that may reorder or drop. Replay + /// protection lives in the transport, which decides what is acceptable. + public func setNonce(_ n: UInt64) throws { + guard n < Noise.maxNonce else { + throw Noise.NoiseError(code: "E_NOISE_NONCE_EXHAUSTED", message: "nonce \(n) is out of range") + } + nonce = n + } + + public func encrypt(ad: Bytes, _ plaintext: Bytes) throws -> Bytes { + try assertUsable() + // Before the first mixKey there is no key and the spec passes data + // through. A destroyed cipher is a separate flag so it can never + // quietly emit plaintext. + guard let key else { return plaintext } + let n = try take() + return try Primitives.chachaSeal(key: key, nonce: Noise.nonceBytes(n), ad: ad, plaintext: plaintext) + } + + public func decrypt(ad: Bytes, _ ciphertext: Bytes) throws -> Bytes { + try assertUsable() + guard let key else { return ciphertext } + let n = try take() + do { + return try Primitives.chachaOpen(key: key, nonce: Noise.nonceBytes(n), ad: ad, ciphertext: ciphertext) + } catch { + // Never say more than this. Which check failed is exactly what a + // chosen-ciphertext attacker is probing for. + throw Noise.NoiseError(code: "E_NOISE_AUTH", message: "authentication failed") + } + } + + func copy() -> CipherState { + let c = CipherState(key: key) + c.nonce = nonce + c.destroyed = destroyed + return c + } + + /// Wipe the key. A closed session must not leave one reachable. + public func destroy() { + if var k = key { wipe(&k) } + key = nil + destroyed = true + } + + private func assertUsable() throws { + if destroyed { throw Noise.NoiseError(code: "E_NOISE_CLOSED", message: "cipher was destroyed") } + } + + private func take() throws -> UInt64 { + guard nonce < Noise.maxNonce else { + throw Noise.NoiseError(code: "E_NOISE_NONCE_EXHAUSTED", message: "nonce space exhausted") + } + defer { nonce += 1 } + return nonce + } +} + +/// Chaining key, handshake hash, and the cipher over handshake payloads. +final class SymmetricState { + var ck: Bytes + var h: Bytes + var cipher = CipherState() + + init() { + let name = Array(Noise.protocolName.utf8) + // The name is exactly 32 bytes, so it is used verbatim; a longer one + // would be hashed. + h = name.count <= Noise.hashBytes ? name + Bytes(repeating: 0, count: Noise.hashBytes - name.count) : Primitives.sha256(name) + ck = h + } + + func mixHash(_ data: Bytes) { + h = Primitives.sha256(h + data) + } + + func mixKey(_ ikm: Bytes) { + let (next, temp) = Noise.hkdf2(ck, ikm) + ck = next + cipher = CipherState(key: temp) + } + + func encryptAndHash(_ plaintext: Bytes) throws -> Bytes { + let ct = try cipher.encrypt(ad: h, plaintext) + mixHash(ct) + return ct + } + + func decryptAndHash(_ ciphertext: Bytes) throws -> Bytes { + let pt = try cipher.decrypt(ad: h, ciphertext) + mixHash(ciphertext) + return pt + } + + func copy() -> SymmetricState { + let c = SymmetricState() + c.ck = ck + c.h = h + c.cipher = cipher.copy() + return c + } + + func split() -> (CipherState, CipherState) { + let (k1, k2) = Noise.hkdf2(ck, []) + return (CipherState(key: k1), CipherState(key: k2)) + } +} + +public struct HandshakeResult { + /// Encrypts what we send. + public let send: CipherState + /// Decrypts what we receive. + public let recv: CipherState + public let handshakeHash: Bytes + /// The peer's static key, now authenticated rather than merely claimed. + public let remoteStatic: Bytes +} + +/// The IK state machine. Anything out of order throws rather than producing +/// a message the peer cannot read. +public final class HandshakeState { + private enum Step { case write1, read1, write2, read2, done, split } + + private var sym = SymmetricState() + private let s: Primitives.KeyPair + private var e: Primitives.KeyPair? + private var rs: Bytes? + private var re: Bytes? + private let initiator: Bool + private let fixedEphemeral: Primitives.KeyPair? + private var step: Step + + /// `ephemeral` is for test vectors only: generating it is the whole + /// source of forward secrecy. + public static func initiator(staticKey: Primitives.KeyPair, remoteStatic: Bytes, prologue: Bytes = [], ephemeral: Primitives.KeyPair? = nil) throws -> HandshakeState { + guard remoteStatic.count == Noise.dhBytes else { + throw Noise.NoiseError(code: "E_NOISE_KEY", message: "IK needs the responder static key up front") + } + return HandshakeState(initiator: true, staticKey: staticKey, remoteStatic: remoteStatic, prologue: prologue, ephemeral: ephemeral) + } + + public static func responder(staticKey: Primitives.KeyPair, prologue: Bytes = [], ephemeral: Primitives.KeyPair? = nil) -> HandshakeState { + HandshakeState(initiator: false, staticKey: staticKey, remoteStatic: nil, prologue: prologue, ephemeral: ephemeral) + } + + private init(initiator: Bool, staticKey: Primitives.KeyPair, remoteStatic: Bytes?, prologue: Bytes, ephemeral: Primitives.KeyPair?) { + self.initiator = initiator + s = staticKey + fixedEphemeral = ephemeral + step = initiator ? .write1 : .read1 + sym.mixHash(prologue) + // IK's pre-message `<- s`: both ends hash the responder's static key + // before a byte moves, so a relay substituting its own key fails at + // the first decryption. + if initiator, let remoteStatic { + rs = remoteStatic + sym.mixHash(remoteStatic) + } else { + sym.mixHash(staticKey.publicKey) + } + } + + public var isComplete: Bool { step == .done } + + public func writeMessage(_ payload: Bytes = []) throws -> Bytes { + if initiator && step == .write1 { return try writeMessage1(payload) } + if !initiator && step == .write2 { return try writeMessage2(payload) } + throw Noise.NoiseError(code: "E_NOISE_STATE", message: "cannot write at this step") + } + + public func readMessage(_ message: Bytes) throws -> Bytes { + if !initiator && step == .read1 { return try readMessage1(message) } + if initiator && step == .read2 { return try readMessage2(message) } + throw Noise.NoiseError(code: "E_NOISE_STATE", message: "cannot read at this step") + } + + /// Ends the handshake and hands over the keys, once. Splitting twice + /// would mint two send ciphers with the same key at nonce 0. + public func split() throws -> HandshakeResult { + guard step == .done else { + throw Noise.NoiseError(code: "E_NOISE_STATE", message: step == .split ? "handshake already split" : "handshake is not done") + } + let (c1, c2) = sym.split() + step = .split + return HandshakeResult( + send: initiator ? c1 : c2, + recv: initiator ? c2 : c1, + handshakeHash: sym.h, + remoteStatic: rs ?? [] + ) + } + + // -> e, es, s, ss + private func writeMessage1(_ payload: Bytes) throws -> Bytes { + let e = fixedEphemeral ?? Primitives.generateKeyPair() + self.e = e + let rs = self.rs! + sym.mixHash(e.publicKey) + sym.mixKey(try Noise.dh(e.secretKey, rs)) + let encStatic = try sym.encryptAndHash(s.publicKey) + sym.mixKey(try Noise.dh(s.secretKey, rs)) + let encPayload = try sym.encryptAndHash(payload) + step = .read2 + return e.publicKey + encStatic + encPayload + } + + private func readMessage1(_ message: Bytes) throws -> Bytes { + let encStaticEnd = Noise.dhBytes + Noise.dhBytes + Noise.tagBytes + guard message.count >= encStaticEnd + Noise.tagBytes else { + throw Noise.NoiseError(code: "E_NOISE_MESSAGE", message: "handshake message 1 is \(message.count) bytes") + } + // Commit on success: a message that fails to authenticate must leave + // no trace, or one stray frame ends every handshake still waiting. + let sym = self.sym.copy() + let re = Array(message[0.. Bytes { + let e = fixedEphemeral ?? Primitives.generateKeyPair() + self.e = e + sym.mixHash(e.publicKey) + sym.mixKey(try Noise.dh(e.secretKey, re!)) + sym.mixKey(try Noise.dh(e.secretKey, rs!)) + let encPayload = try sym.encryptAndHash(payload) + step = .done + return e.publicKey + encPayload + } + + private func readMessage2(_ message: Bytes) throws -> Bytes { + guard message.count >= Noise.dhBytes + Noise.tagBytes else { + throw Noise.NoiseError(code: "E_NOISE_MESSAGE", message: "handshake message 2 is \(message.count) bytes") + } + // Mixing runs against a copy that becomes the state only once the tag + // verifies. Two phones connecting at once read each other's 48-byte + // replies on a shared relay room; mixing a stray one would poison the + // state and the genuine reply could never authenticate. + let sym = self.sym.copy() + let re = Array(message[0.. Bytes { + Bytes(SHA256.hash(data: data)) + } + + public static func hmacSHA256(key: Bytes, _ data: Bytes) -> Bytes { + Bytes(HMAC.authenticationCode(for: data, using: SymmetricKey(data: key))) + } + + /// RFC 5869 HKDF with SHA-256. An empty salt is a salt of zeros, as the + /// RFC and WebCrypto both say. + public static func hkdf(ikm: Bytes, salt: Bytes, info: Bytes, length: Int) -> Bytes { + let key = HKDF.deriveKey( + inputKeyMaterial: SymmetricKey(data: ikm), + salt: salt, + info: info, + outputByteCount: length + ) + return key.withUnsafeBytes { Bytes($0) } + } + + // MARK: X25519 + + public struct KeyPair: Sendable { + public let secretKey: Bytes + public let publicKey: Bytes + } + + public static func generateKeyPair() -> KeyPair { + let key = Curve25519.KeyAgreement.PrivateKey() + return KeyPair(secretKey: Bytes(key.rawRepresentation), publicKey: Bytes(key.publicKey.rawRepresentation)) + } + + public static func keyPair(fromSecret secret: Bytes) throws -> KeyPair { + guard secret.count == 32 else { throw CryptoFailure(message: "static key is \(secret.count) bytes, need 32") } + let key = try Curve25519.KeyAgreement.PrivateKey(rawRepresentation: secret) + return KeyPair(secretKey: secret, publicKey: Bytes(key.publicKey.rawRepresentation)) + } + + /// X25519. Throws on a low-order peer key, whose shared secret is all + /// zeros: the spec permits accepting it and refusing is strictly safer. + public static func x25519(secret: Bytes, peer: Bytes) throws -> Bytes { + do { + let mine = try Curve25519.KeyAgreement.PrivateKey(rawRepresentation: secret) + let theirs = try Curve25519.KeyAgreement.PublicKey(rawRepresentation: peer) + let shared = try mine.sharedSecretFromKeyAgreement(with: theirs) + let out = shared.withUnsafeBytes { Bytes($0) } + if out.allSatisfy({ $0 == 0 }) { throw CryptoFailure(message: "low-order peer key") } + return out + } catch let e as CryptoFailure { + throw e + } catch { + throw CryptoFailure(message: "X25519 failed") + } + } + + // MARK: ChaCha20-Poly1305 + + public static func chachaSeal(key: Bytes, nonce: Bytes, ad: Bytes, plaintext: Bytes) throws -> Bytes { + let box = try ChaChaPoly.seal( + plaintext, + using: SymmetricKey(data: key), + nonce: try ChaChaPoly.Nonce(data: nonce), + authenticating: ad + ) + return Bytes(box.ciphertext) + Bytes(box.tag) + } + + public static func chachaOpen(key: Bytes, nonce: Bytes, ad: Bytes, ciphertext: Bytes) throws -> Bytes { + guard ciphertext.count >= 16 else { throw CryptoFailure(message: "ciphertext shorter than its tag") } + let box = try ChaChaPoly.SealedBox( + nonce: try ChaChaPoly.Nonce(data: nonce), + ciphertext: ciphertext[..<(ciphertext.count - 16)], + tag: ciphertext[(ciphertext.count - 16)...] + ) + return Bytes(try ChaChaPoly.open(box, using: SymmetricKey(data: key), authenticating: ad)) + } + + // MARK: AES-256-GCM + + /// Ciphertext followed by the 16-byte tag, the layout WebCrypto returns, + /// so a blob sealed by the web app opens here and the reverse. + public static func aesGCMSeal(key: Bytes, nonce: Bytes, ad: Bytes = [], plaintext: Bytes) throws -> Bytes { + let box = try AES.GCM.seal( + plaintext, + using: SymmetricKey(data: key), + nonce: try AES.GCM.Nonce(data: nonce), + authenticating: ad + ) + return Bytes(box.ciphertext) + Bytes(box.tag) + } + + public static func aesGCMOpen(key: Bytes, nonce: Bytes, ad: Bytes = [], ciphertext: Bytes) throws -> Bytes { + guard ciphertext.count >= 16 else { throw CryptoFailure(message: "ciphertext shorter than its tag") } + let box = try AES.GCM.SealedBox( + nonce: try AES.GCM.Nonce(data: nonce), + ciphertext: ciphertext[..<(ciphertext.count - 16)], + tag: ciphertext[(ciphertext.count - 16)...] + ) + return Bytes(try AES.GCM.open(box, using: SymmetricKey(data: key), authenticating: ad)) + } + + // MARK: Ed25519 + + /// The public half of an RFC 8032 seed. Deterministic, so the same PRF + /// output yields the same escrow write key here and in the web app. + public static func ed25519PublicKey(seed: Bytes) throws -> Bytes { + Bytes(try Curve25519.Signing.PrivateKey(rawRepresentation: seed).publicKey.rawRepresentation) + } + + public static func ed25519Sign(seed: Bytes, message: Bytes) throws -> Bytes { + Bytes(try Curve25519.Signing.PrivateKey(rawRepresentation: seed).signature(for: message)) + } + + public static func ed25519Verify(publicKey: Bytes, signature: Bytes, message: Bytes) -> Bool { + guard let key = try? Curve25519.Signing.PublicKey(rawRepresentation: publicKey) else { return false } + return key.isValidSignature(signature, for: message) + } +} diff --git a/appleApp/FTWKit/Sources/FTWKit/Crypto/Transport.swift b/appleApp/FTWKit/Sources/FTWKit/Crypto/Transport.swift new file mode 100644 index 0000000..5e3634e --- /dev/null +++ b/appleApp/FTWKit/Sources/FTWKit/Crypto/Transport.swift @@ -0,0 +1,98 @@ +import Foundation + +/// The encrypted channel once the handshake is done. +/// +/// offset field note +/// 0 seq u64BE cleartext, authenticated as associated data +/// 8 body ChaCha20-Poly1305 over the frame, tag included +/// +/// The sequence number is on the wire so a carrier that reorders or drops +/// (the LAN path, later) costs one frame instead of the session. Eight bytes +/// of monotonic counter and nothing else, so frame size stays constant. +public final class NoiseTransport { + public static let seqBytes = 8 + public static let overhead = seqBytes + Noise.tagBytes + /// At 1 Hz on lane 0, 64 frames is a minute of tolerance and one word. + public static let replayWindow: UInt64 = 64 + + public let handshakeHash: Bytes + /// The box's static key as authenticated, not as claimed. + public let remoteStatic: Bytes + + private let send: CipherState + private let recv: CipherState + private var highestSeq: UInt64? + private var seen: UInt64 = 0 + private var closed = false + + public init(_ result: HandshakeResult) { + send = result.send + recv = result.recv + handshakeHash = result.handshakeHash + remoteStatic = result.remoteStatic + } + + public var nextSeq: UInt64 { send.nonce } + + public func encrypt(_ frame: Bytes) throws -> Bytes { + try assertOpen() + let seq = send.nonce.bigEndianBytes + let body = try send.encrypt(ad: seq, frame) + return seq + body + } + + public func decrypt(_ bytes: Bytes) throws -> Bytes { + try assertOpen() + guard bytes.count >= Self.overhead else { + throw Noise.NoiseError(code: "E_NOISE_MESSAGE", message: "transport message is \(bytes.count) bytes") + } + let seqBytes = Array(bytes[0..= Self.replayWindow { + throw Noise.NoiseError(code: "E_NOISE_REPLAY", message: "sequence \(seq) is outside the replay window") + } + if (seen >> behind) & 1 == 1 { + throw Noise.NoiseError(code: "E_NOISE_REPLAY", message: "sequence \(seq) was already accepted") + } + } + + private func markSeen(_ seq: UInt64) { + guard let highest = highestSeq else { + highestSeq = seq + seen = 1 + return + } + if seq > highest { + let shift = seq - highest + seen = shift >= Self.replayWindow ? 1 : (seen << shift) | 1 + highestSeq = seq + } else { + seen |= 1 << (highest - seq) + } + } +} diff --git a/appleApp/FTWKit/Sources/FTWKit/Demo/SimulatedBox.swift b/appleApp/FTWKit/Sources/FTWKit/Demo/SimulatedBox.swift new file mode 100644 index 0000000..69506d1 --- /dev/null +++ b/appleApp/FTWKit/Sources/FTWKit/Demo/SimulatedBox.swift @@ -0,0 +1,709 @@ +import Foundation + +/// A box that lives in this process: the other end of the protocol, for the +/// demo and for the tests. It speaks frames exactly as a box does, holds a +/// `SimulatedHouse`, and answers the same routes and commands the app uses. +/// +/// It is not the box and says so: the demo runs behind its own band and +/// writes nothing to disk. +@MainActor +public final class SimulatedBox { + public let house: SimulatedHouse + private let scheduler: Scheduler + private let requireStepUp: Bool + private let bootedAtMs: Double + + /// Frames to the app. + public var send: (@MainActor (Bytes) -> Void)? + + private var subscription: Subscription? + private var telemetry: Cancellable? + private var seq: UInt64 = 0 + private var controlRev: UInt64 = 1 + private var lastSent: [Int: Double] = [:] + private var planRev: UInt64 = 1 + public var booting = false + /// Silence instead of answers, to test the app's deadlines. + public var mute = false + /// Ack commands but never report a result. + public var neverConfirm = false + public private(set) var receivedCommands: [String] = [] + public private(set) var stepUpsSeen = 0 + + // Box-side records the Box screen reads. + var devices: [(id: String, role: String, addedAtMs: Double, lastSeenMs: Double)] = [] + var notifyEnabled = false + var notifyRules: [(type: String, enabled: Bool)] = [ + ("charging.connected", false), ("charging.session_complete", false), ("charging.interrupted", false), + ("update.installed", false), ("driver.offline", false), ("fuse.over_limit", false), + ] + var sentPushes: [(title: String, atMs: Double)] = [] + + public static let modes: [ModeInfo] = [ + ModeInfo(key: "planner_passive_arbitrage", label: "Passive arbitrage", tooltip: "Charge from the cheapest available source (PV when sunny, grid during cheap hours). Never exports from battery.", tier: "primary"), + ModeInfo(key: "planner_arbitrage", label: "Active arbitrage", tooltip: "Full price arbitrage — charge cheap, discharge into expensive hours (battery may export to grid).", tier: "primary"), + ModeInfo(key: "idle", label: "Stop batteries", tooltip: "Hold every battery at 0 W for as long as this mode is on.", tier: "advanced"), + ModeInfo(key: "self_consumption", label: "Self (manual)", tooltip: "Manual self-consumption — PI chases grid target, no plan.", tier: "advanced"), + ModeInfo(key: "peak_shaving", label: "Peak", tooltip: "Limit grid import to the configured peak limit.", tier: "advanced"), + ModeInfo(key: "charge", label: "Charge", tooltip: "Force full charge regardless of price.", tier: "advanced"), + ModeInfo(key: "planner_self", label: "Planner (self)", tooltip: "Forecast-driven self-consumption.", tier: "hidden"), + ] + + public init(scheduler: Scheduler, house: SimulatedHouse = SimulatedHouse(), requireStepUp: Bool = false, uptimeAtStartMs: Double = 3_600_000) { + self.scheduler = scheduler + self.house = house + self.requireStepUp = requireStepUp + bootedAtMs = scheduler.nowMs - uptimeAtStartMs + house.step(nowMs: scheduler.nowMs, dtMs: 1_000) + devices = [("Qm94T3du", Contract.roleOwner, scheduler.nowMs - 40 * 86_400_000, scheduler.nowMs - 3 * 3_600_000)] + } + + var uptimeMs: Double { scheduler.nowMs - bootedAtMs } + + /// Move the house and send what a box would send for that second. + public func tick(_ dtMs: Double = 1_000) { + house.step(nowMs: scheduler.nowMs, dtMs: dtMs) + } + + public func stop() { + telemetry?.cancel() + telemetry = nil + send = nil + } + + /// Register a phone, as a pairing on the box would. + public func enroll(deviceID: String, role: String) { + devices.insert((deviceID, role, scheduler.nowMs, scheduler.nowMs), at: 0) + } + + // MARK: Frames in + + public func receive(_ bytes: Bytes) { + if mute { return } + guard let frame = try? Frame.decode(bytes) else { return } + let env = frame.envelope + let body = env.b ?? CBOR.emptyMap + switch env.t { + case "hello": onHello(body) + case "sub": + let first = subscription == nil + subscription = Subscription(bucket: body["bucket"]?.int ?? 512, hz: body["hz"]?.double ?? 1) + if first { sendSnap() } + restartTelemetry() + case "plan.get": sendBulk(Envelope(t: "plan", id: env.id, b: planCBOR())) + case "price.get": + // The box decodes times into int64 and drops a body with a + // fraction in one, answering nothing. So does this. + guard let from = Self.wholeMs(body["fromMs"]), let to = Self.wholeMs(body["toMs"]) else { return } + sendBulk(Envelope(t: "price", id: env.id, b: pricesCBOR(from: from, to: to))) + case "hist.query": + guard Self.wholeMs(body["fromMs"]) != nil, Self.wholeMs(body["toMs"]) != nil else { return } + onHistory(env.id, body) + case "api.req": onAPI(env.id, body) + case "cmd": onCommand(body) + default: break + } + } + + /// An integer, as Go's int64 decoding demands; a float is refused. + static func wholeMs(_ c: CBOR?) -> Double? { + switch c { + case .unsigned(let u)?: return Double(u) + case .negative(let n)?: return -1 - Double(n) + default: return nil + } + } + + private func onHello(_ body: CBOR) { + let wantsSub = body["sub"] + var pairs: [(String, CBOR)] = [ + ("proto", .int(Proto.max)), + ("mode", .text(booting ? "booting" : "full")), + ("box", .map([("id", .text("demo-box")), ("build", .text("0.131.0-demo")), ("tz", .text(house.timeZone.identifier))])), + ("clock", .map([("source", .text("ntp")), ("syncedAtMs", .number(scheduler.nowMs - 60_000)), ("uptimeMs", .number(uptimeMs))])), + ("caps", .array(["status.core", "status.drivers", "history.5m", "history.1h", "history.etag", "cmd.lease", "cmd.readback", "der.battery", "der.ev", "plan.dispatch", "price.spot", "api.passthrough"].map { .text($0) })), + ("capsHash", .text("demo")), + ("modes", .array(Self.modes.map { .map([("key", .text($0.key)), ("label", .text($0.label)), ("tooltip", .text($0.tooltip)), ("tier", .text($0.tier))]) })), + ("role", .text(Contract.roleOwner)), + ("scopes", .array(Contract.scopes.map { .text($0) })), + ] + if booting { + pairs.append(("boot", .map([("phase", .text("vacuum")), ("pct", .int(40)), ("etaMs", .null)]))) + } else if let sub = wantsSub, sub.entries != nil { + pairs.append(("subscribed", .bool(true))) + } + sendBulk(Envelope(t: "hello_ok", b: .map(pairs))) + if !booting, let sub = wantsSub, sub.entries != nil { + subscription = Subscription(bucket: sub["bucket"]?.int ?? 512, hz: sub["hz"]?.double ?? 1) + sendSnap() + restartTelemetry() + } + } + + // MARK: Telemetry + + var fields: [Int: Double] { + var f: [Int: Double] = [ + Contract.FID.mode: Double(Self.modes.firstIndex { $0.key == house.modeKey } ?? 0), + Contract.FID.gridW: house.gridW, + Contract.FID.pvW: house.pvW, + Contract.FID.batteryW: house.batteryW, + Contract.FID.batterySoc: (house.socFraction * 1000).rounded(), + Contract.FID.loadW: house.baseLoadW + house.evWatts, + ] + f[Contract.FID.evW] = house.evWatts + return f + } + + private func sourcesCBOR() -> CBOR { + let now = uptimeMs + func src(_ kind: String, _ name: String) -> CBOR { + .map([("kind", .text(kind)), ("name", .text(name)), ("lastOkMs", .number(now - 400)), ("staleAfterMs", .int(10_000)), ("state", .text("live"))]) + } + return .map([("meter", src("meter", "sdm630")), ("inverter", src("inverter", "sungrow")), ("charger", src("charger", "easee"))]) + } + + private func sendSnap() { + let f = fields + lastSent = f + let dict: [(String, CBOR)] = [ + ("1", .map([("name", .text("mode")), ("unit", .null), ("srcId", .null)])), + ("2", .map([("name", .text("grid_w")), ("unit", .text("W")), ("srcId", .text("meter"))])), + ("3", .map([("name", .text("pv_w")), ("unit", .text("W")), ("srcId", .text("inverter"))])), + ("4", .map([("name", .text("battery_w")), ("unit", .text("W")), ("srcId", .text("inverter"))])), + ("5", .map([("name", .text("battery_soc")), ("unit", .text("permille")), ("srcId", .text("inverter"))])), + ("6", .map([("name", .text("load_w")), ("unit", .text("W")), ("srcId", .text("meter"))])), + ("10", .map([("name", .text("ev_w")), ("unit", .text("W")), ("srcId", .text("charger"))])), + ] + sendBulk(Envelope(t: "snap", b: .map([ + ("uptimeMs", .number(uptimeMs)), + ("controlRev", .unsigned(controlRev)), + ("dict", .map(dict)), + ("fields", .map(f.sorted { $0.key < $1.key }.map { (String($0.key), CBOR.number($0.value)) })), + ("sources", sourcesCBOR()), + ("dispatchBlockedBy", .array([])), + ]))) + } + + private func restartTelemetry() { + telemetry?.cancel() + guard let sub = subscription else { return } + let period = 1_000 / max(0.01, sub.hz) + func loop() { + telemetry = scheduler.after(period) { [weak self] in + guard let self, self.send != nil else { return } + self.tick(period) + self.sendTelemetry() + loop() + } + } + loop() + } + + /// A delta when something moved, a tick when nothing did: the same size + /// and cadence either way. + func sendTelemetry() { + guard let sub = subscription, !mute else { return } + seq += 1 + let f = fields + let changed = f.filter { lastSent[$0.key] != $0.value }.sorted { $0.key < $1.key } + lastSent = f + let env: Envelope + if changed.isEmpty { + env = Envelope(t: "tick", b: .map([("seq", .unsigned(seq)), ("uptimeMs", .number(uptimeMs))])) + } else { + env = Envelope(t: "delta", b: .map([ + ("seq", .unsigned(seq)), + ("uptimeMs", .number(uptimeMs)), + ("fields", .map(changed.map { (String($0.key), CBOR.number($0.value)) })), + ])) + } + if let frame = try? Frame.encode(lane: Frame.laneControl, envelope: env, bucket: sub.bucket) { + send?(frame) + } + } + + // MARK: Plan and prices + + func planSlots() -> [PlanSlot] { + let slotMs: Double = 900_000 + let start = (scheduler.nowMs / slotMs).rounded(.down) * slotMs + return (0..<96).map { i in + let t = start + Double(i) * slotMs + let price = house.priceAt(t) + let pv = house.pvAt(t) + let load = house.baseLoadAt(t) + var battery = 0.0 + var reason = "idle" + if -pv > load + 300 { + battery = min(house.batteryMaxW, -pv - load).rounded(); reason = "solar_surplus" + } else if price < 45 { + battery = house.modeKey == "planner_self" ? 0 : 3_000; reason = battery > 0 ? "cheap_import" : "idle" + } else if price > 110 { + battery = -min(house.batteryMaxW, load).rounded(); reason = "expensive_import" + } else if price > 80 { + reason = "reserve_held" + } + return PlanSlot(startMs: t, durationMs: slotMs, batteryW: battery, gridW: load + battery + pv, priceMinor: price, reason: reason) + } + } + + func planCBOR() -> CBOR { + .map([ + ("rev", .unsigned(planRev)), + ("uptimeMs", .number(uptimeMs)), + ("slots", .array(planSlots().map { s in + .map([ + ("startMs", .number(s.startMs)), ("durationMs", .number(s.durationMs)), + ("batteryW", .number(s.batteryW)), ("gridW", .number(s.gridW)), + ("priceMinor", s.priceMinor.map { CBOR.number($0) } ?? .null), ("reason", .text(s.reason)), + ]) + })), + ("stale", .bool(false)), + ("ceilingW", .int(11_000)), + ]) + } + + func pricesCBOR(from: Double, to: Double) -> CBOR { + let slotMs: Double = 3_600_000 + // Tomorrow's rates publish at 13:00 local. + let midnight = scheduler.nowMs - house.hourOfDay(scheduler.nowMs) * 3_600_000 + let published = midnight + (house.hourOfDay(scheduler.nowMs) >= 13 ? 48 : 24) * 3_600_000 + let end = min(to, published) + var slots = [CBOR]() + var t = (from / slotMs).rounded(.down) * slotMs + while t < end { + let total = house.priceAt(t) + slots.append(.map([("startMs", .number(t)), ("durationMs", .number(slotMs)), ("spotMinor", .number((total * 0.6).rounded())), ("totalMinor", .number(total))])) + t += slotMs + } + return .map([("zone", .text("SE3")), ("currency", .text("SEK")), ("slots", .array(slots)), ("stale", .bool(end < to))]) + } + + // MARK: History + + private func onHistory(_ id: UInt32?, _ body: CBOR) { + let series = body["series"]?.stringArray ?? [] + let res = body["res"]?.string.flatMap(Resolution.init(rawValue:)) ?? .fiveMinutes + let from = body["fromMs"]?.double ?? scheduler.nowMs - 86_400_000 + let to = body["toMs"]?.double ?? scheduler.nowMs + let maxPoints = body["maxPoints"]?.int ?? 2000 + var have = [String: String]() + for h in body["have"]?.array ?? [] { + if let t = h["tileId"]?.string, let e = h["etag"]?.string { have[t] = e } + } + let plan = HistoryGeometry.plan(res, fromMs: from, toMs: to, maxPoints: maxPoints) + for tile in plan.tiles { + let partial = tile.startMs + HistoryGeometry.spec(plan.res).tileSpanMs > scheduler.nowMs + let columns: [[Int32]] = series.map { name in + (0.. scheduler.nowMs { return missingSample } + let pv = house.pvAt(t) + let load = house.baseLoadAt(t) + let battery = max(-5_000, min(5_000, -(load + pv))) + switch name { + case "pv_w": return Int32(pv) + case "load_w": return Int32(load) + case "battery_w": return Int32(battery) + case "grid_w": return Int32(load + battery + pv) + default: return missingSample + } + } + } + let data = HistoryGeometry.pack(columns) + let etag = HistoryGeometry.etag(data) + if !partial, have[tile.tileId] == etag { continue } + sendBulk(Envelope(t: "hist.chunk", id: id, b: .map([ + ("tileId", .text(tile.tileId)), ("etag", .text(etag)), ("res", .text(plan.res.rawValue)), + ("startMs", .number(tile.startMs)), ("stepMs", .number(plan.stepMs)), + ("series", .array(series.map { .text($0) })), ("data", .bytes(data)), ("partial", .bool(partial)), + ]))) + } + sendBulk(Envelope(t: "hist.end", id: id, b: .map([("resActual", .text(plan.res.rawValue)), ("gaps", .array([]))]))) + } + + // MARK: Commands + + private func onCommand(_ body: CBOR) { + let cmdId = body["cmdId"]?.string ?? "" + let op = body["op"]?.string ?? "" + let args = body["args"] ?? CBOR.emptyMap + receivedCommands.append(op) + if let notAfter = body["notValidAfterMs"]?.double, notAfter < uptimeMs { + result(cmdId, "expired", error: ("E_CMD_EXPIRED", [])) + return + } + sendControl(Envelope(t: "cmd.ack", b: .map([("cmdId", .text(cmdId)), ("leaseId", .text("lease-\(cmdId.prefix(8))")), ("expiresAtMs", .number(uptimeMs + 60_000))]))) + if neverConfirm { return } + + switch op { + case Contract.opSetMode: + guard let mode = args["mode"]?.string, Self.modes.contains(where: { $0.key == mode }) else { + result(cmdId, "rejected", error: ("E_PRECONDITION", [])) + return + } + house.modeKey = mode + controlRev += 1 + planRev += 1 + result(cmdId, "applied", observed: Double(Self.modes.firstIndex { $0.key == mode } ?? 0)) + // A plan pushed unasked after a mode change, as the box does. + sendBulk(Envelope(t: "plan", b: planCBOR())) + case Contract.opLoadpointHold: + if args["clear"]?.bool == true { + house.manualHoldW = nil + } else { + house.manualHoldW = args["power_w"]?.double ?? 0 + } + result(cmdId, "applied", observed: house.manualHoldW ?? 0) + case Contract.opLoadpointBoost: + if args["cancel"]?.bool == true { + house.boostUntilMs = nil + house.lastBoostStop = "cancelled" + } else if house.manualHoldW != nil || house.surplusOnly { + result(cmdId, "rejected", error: ("E_UNAVAILABLE", [("op", .text(op))])) + return + } else { + house.boostReserve = (args["min_battery_soc_pct"]?.double ?? 30) / 100 + house.boostUntilMs = scheduler.nowMs + (args["duration_s"]?.double ?? 3_600) * 1000 + } + result(cmdId, "applied") + case Contract.opLoadpointSocSet: + guard house.carPluggedIn else { + result(cmdId, "rejected", error: ("E_UNAVAILABLE", [("op", .text(op)), ("reason", .text("unplugged"))])) + return + } + house.carSocFraction = max(0, min(1, args["soc"]?.double ?? house.carSocFraction)) + house.carSocSource = "inferred" + result(cmdId, "applied", observed: house.carSocFraction) + case Contract.opLoadpointSurplusOnlySet: + house.surplusOnly = args["surplus_only"]?.bool ?? false + result(cmdId, "applied", observed: house.surplusOnly ? 1 : 0) + default: + result(cmdId, "rejected", error: ("E_UNKNOWN_OP", [])) + } + } + + private func result(_ cmdId: String, _ state: String, observed: Double? = nil, error: (String, [(String, CBOR)])? = nil) { + var pairs: [(String, CBOR)] = [("cmdId", .text(cmdId)), ("state", .text(state))] + if let observed { pairs.append(("observed", .map([("value", .number(observed)), ("src", .text("core")), ("uptimeMs", .number(uptimeMs))]))) } + if let error { pairs.append(("error", .map([("code", .text(error.0)), ("args", .map(error.1))]))) } + sendControl(Envelope(t: "cmd.result", b: .map(pairs))) + } + + // MARK: The box's own API + + enum Tier { case read, configure, actuate, local } + + /// Priced beside the route, never from the method, as the box does. + static let routes: [(method: String, path: String, tier: Tier, op: String?)] = [ + ("GET", "/api/status", .read, nil), + ("GET", "/api/energy/daily", .read, nil), + ("GET", "/api/savings/daily", .read, nil), + ("GET", "/api/app-link/devices", .read, nil), + ("POST", "/api/app-link/pairing", .configure, nil), + ("DELETE", "/api/app-link/devices/{id}", .configure, nil), + ("GET", "/api/loadpoints", .read, nil), + ("GET", "/api/mpc/plan", .read, nil), + ("POST", "/api/loadpoints/{id}/vehicle", .configure, nil), + ("PUT", "/api/loadpoints/{id}/schedule", .configure, nil), + ("DELETE", "/api/loadpoints/{id}/schedule", .configure, nil), + ("POST", "/api/loadpoints/{id}/soc", .actuate, Contract.opLoadpointSocSet), + ("POST", "/api/mode", .actuate, Contract.opSetMode), + ("GET", "/api/notifications/vapid", .read, nil), + ("GET", "/api/notifications/history", .read, nil), + ("GET", "/api/notifications/rules", .read, nil), + ("PUT", "/api/notifications/rules", .configure, nil), + ("POST", "/api/notifications/test", .configure, nil), + ("POST", "/api/restart", .configure, nil), + ("GET", "/api/config", .local, nil), + ("GET", "/api/support/dump", .local, nil), + ] + + static func match(_ method: String, _ path: String) -> (tier: Tier, op: String?, pattern: String, id: String?)? { + let parts = path.split(separator: "/", omittingEmptySubsequences: false) + for r in routes where r.method == method { + let want = r.path.split(separator: "/", omittingEmptySubsequences: false) + guard want.count == parts.count else { continue } + var id: String? + var ok = true + for (a, b) in zip(want, parts) { + if a == "{id}" { id = String(b).removingPercentEncoding } else if a != b { ok = false; break } + } + if ok { return (r.tier, r.op, r.path, id) } + } + return nil + } + + private func onAPI(_ id: UInt32?, _ body: CBOR) { + let method = body["method"]?.string ?? "GET" + let path = body["path"]?.string ?? "" + let stepUp = body["stepUp"]?.bool == true + var query = [String: String]() + for e in body["query"]?.entries ?? [] { if let k = e.key.string, let v = e.value.string { query[k] = v } } + let requestBody = body["body"]?.byteString.flatMap { try? JSON(parsing: $0) } + + guard path.hasPrefix("/api/"), let route = Self.match(method, path) else { + refuse(id, "E_UNKNOWN_OP", []) + return + } + switch route.tier { + case .local: + refuse(id, "E_LOCAL_ONLY", []) + return + case .actuate: + refuse(id, "E_USE_CMD", route.op.map { [("op", .text($0))] } ?? []) + return + case .configure: + if stepUp { stepUpsSeen += 1 } + if requireStepUp && !stepUp { + refuse(id, "E_NEEDS_STEP_UP", []) + return + } + case .read: + break + } + let (status, json) = answer(method, route.pattern, route.id, query, requestBody) + respond(id, status: status, json: json) + } + + private func answer(_ method: String, _ pattern: String, _ itemID: String?, _ query: [String: String], _ body: JSON?) -> (Int, JSON) { + let now = scheduler.nowMs + switch (method, pattern) { + case ("GET", "/api/status"): + let perPhase = house.gridW / 3 + return (200, [ + "grid_w": .number(house.gridW), "pv_w": .number(house.pvW), "bat_w": .number(house.batteryW), + "load_w": .number(house.baseLoadW + house.evWatts), + "energy": ["today": [ + "import_wh": .number(house.todayImportWh), "export_wh": .number(house.todayExportWh), + "pv_wh": .number(house.todayPVWh), "load_wh": .number(house.todayLoadWh), + "bat_charged_wh": .number(house.todayChargedWh), "bat_discharged_wh": .number(house.todayDischargedWh), + ]], + "drivers": [ + "sungrow": ["status": "ok", "pv_w": .number(house.pvW), "bat_w": .number(house.batteryW), "bat_soc": .number(house.socFraction)], + "easee": ["status": "ok", "ev_w": .number(house.evWatts)], + ], + "fuse": ["max_amps": .number(house.fuseAmps), "phases": 3, "voltage": 230], + "phase_amps": .array((0..<3).map { .number(((perPhase + Double($0) * 90) / 230 * 10).rounded() / 10) }), + "phase_powers": .array((0..<3).map { .number((perPhase + Double($0) * 90).rounded()) }), + ]) + case ("GET", "/api/energy/daily"): + let days = max(1, min(90, Int(query["days"] ?? "7") ?? 7)) + let rows: [JSON] = (0..= 1_000 { house.carCapacityWh = wh } + return (200, ["ok": true]) + case ("PUT", "/api/loadpoints/{id}/schedule"): + house.schedule = ( + body?["soc"]?.number ?? 0.8, + Int(body?["time_of_day_min_utc"]?.number ?? 300), + body?["recurring"]?.bool ?? false, + Int(body?["days"]?.number ?? 0), + body?["surplus_unlock_bat_soc"]?.number ?? 0 + ) + return (200, ["ok": true]) + case ("DELETE", "/api/loadpoints/{id}/schedule"): + house.schedule = nil + return (200, ["ok": true]) + case ("GET", "/api/app-link/devices"): + return (200, ["devices": .array(devices.map { ["id": .string($0.id), "role": .string($0.role), "added_at_ms": .number($0.addedAtMs), "last_seen_ms": .number($0.lastSeenMs)] })]) + case ("POST", "/api/app-link/pairing"): + let role = body?["role"]?.string ?? "" + guard role == Contract.roleViewer || role == Contract.roleOwner else { return (400, ["code": "E_BAD_ROLE"]) } + let invite = Enrollment(boxStaticPublic: Bytes(repeating: 7, count: 32), pairingCode: randomBytes(16), lanHint: "", rendezvousSecret: randomBytes(32)) + return (200, ["url": .string(invite.url()), "role": .string(role), "expires_at_ms": .number(now + 600_000)]) + case ("DELETE", "/api/app-link/devices/{id}"): + guard let itemID, devices.contains(where: { $0.id == itemID }) else { return (404, [:]) } + let owners = devices.filter { $0.role == Contract.roleOwner } + if owners.count == 1, owners.first?.id == itemID { return (409, ["code": "E_LAST_OWNER_PROTECTED"]) } + devices.removeAll { $0.id == itemID } + return (200, ["ok": true]) + case ("GET", "/api/notifications/vapid"): + return (200, ["public_key": "BDemoKeyNotForSending"]) + case ("GET", "/api/notifications/history"): + return (200, ["events": .array(sentPushes.reversed().map { ["title": .string($0.title), "at_ms": .number($0.atMs)] })]) + case ("GET", "/api/notifications/rules"): + return (200, rulesJSON()) + case ("PUT", "/api/notifications/rules"): + notifyEnabled = body?["enabled"]?.bool ?? notifyEnabled + for rule in body?["events"]?.array ?? [] { + guard let type = rule["type"]?.string, let i = notifyRules.firstIndex(where: { $0.type == type }) else { continue } + notifyRules[i].enabled = rule["enabled"]?.bool ?? false + } + return (200, rulesJSON()) + case ("POST", "/api/notifications/test"): + sentPushes.append(("Test from your FTW box", now)) + return (200, ["ok": true]) + case ("POST", "/api/restart"): + return (200, ["status": "restarting"]) + default: + return (404, [:]) + } + } + + private func rulesJSON() -> JSON { + ["enabled": .bool(notifyEnabled), "events": .array(notifyRules.map { ["type": .string($0.type), "enabled": .bool($0.enabled), "threshold": 0] })] + } + + func loadpointJSON() -> JSON { + let h = house + var lp: JSON = [ + "id": "garage", + "driver_name": "easee", + "plugged_in": .bool(h.carPluggedIn), + "vehicle_capacity_wh": .number(h.carCapacityWh), + "capacity_source": "user", + "soc_retention": "session", + "current_soc": .number(h.carSocFraction), + "soc_source": .string(h.carSocSource), + "current_power_w": .number(h.evWatts), + "delivered_wh_session": .number(h.sessionWh), + "min_charge_w": 4_140, + "max_charge_w": 11_000, + "phases": 3, + "voltage_v": 230, + "manual_active": .bool(h.manualHoldW != nil), + "manual_charge_w": h.manualHoldW.map { .number($0) } ?? .null, + "surplus_only": .bool(h.surplusOnly), + "charger": ["known": true, "available": true, "updated_at_ms": .number(scheduler.nowMs - 2_000)], + "plan_pending": false, + "plan_outdated": false, + "commanded_known": true, + "commanded_w": .number(h.evWatts), + "commanded_reason": "", + "battery_boost": [ + "active": .bool(h.boostUntilMs != nil), + "expires_at_ms": h.boostUntilMs.map { .number($0) } ?? .null, + "min_battery_soc": .number(h.boostReserve), + "stop_reason": .string(h.lastBoostStop ?? ""), + ], + ] + if let manual = h.manualHoldW { + lp = lp.setting("manual", [ + "active": true, + "state": manual == 0 ? "paused" : (h.evWatts > 100 ? "charging" : "sent"), + "requested_w": .number(manual), + "requested_a": .number((manual / 690).rounded()), + ]) + } + if let s = h.schedule { + lp = lp.setting("schedule", [ + "soc": .number(s.socFraction), "time_of_day_min_utc": .number(Double(s.minuteUTC)), + "recurring": .bool(s.recurring), "days": .number(Double(s.days)), "surplus_unlock_bat_soc": .number(s.surplusUnlock), + ]) + // The next cheap night, as one window. + let midnight = scheduler.nowMs - h.hourOfDay(scheduler.nowMs) * 3_600_000 + let start = midnight + (h.hourOfDay(scheduler.nowMs) >= 5 ? 25 : 1) * 3_600_000 + let needWh = max(0, (s.socFraction - h.carSocFraction) * h.carCapacityWh) + lp = lp.setting("plan_windows", needWh > 0 ? [["start_ms": .number(start), "end_ms": .number(start + needWh / 11_000 * 3_600_000), "wh": .number(needWh.rounded())]] : []) + lp = lp.setting("target_soc", .number(s.socFraction)) + } else { + lp = lp.setting("plan_windows", []) + } + return lp + } + + // MARK: Frames out + + private func refuse(_ id: UInt32?, _ code: String, _ args: [(String, CBOR)]) { + sendBulk(Envelope(t: "error", id: id, b: .map([("code", .text(code)), ("retryable", .bool(Contract.isRetryable(code))), ("args", .map(args))]))) + } + + private func respond(_ id: UInt32?, status: Int, json: JSON) { + let body = json.encoded() + sendBulk(Envelope(t: "api.head", id: id, b: .map([("status", .int(status)), ("headers", .map([("content-type", .text("application/json"))])), ("len", .int(body.count))]))) + var seq = 0 + var at = 0 + while at < body.count { + let end = min(body.count, at + 12_288) + sendBulk(Envelope(t: "api.chunk", id: id, b: .map([("seq", .int(seq)), ("data", .bytes(Array(body[at.. Void = { _ in } + private var onStatus: @MainActor (CarrierStatus) -> Void = { _ in } + + public init(box: SimulatedBox, scheduler: Scheduler, latencyMs: Double = 120) { + self.box = box + self.scheduler = scheduler + self.latencyMs = latencyMs + box.send = { [weak self] frame in + guard let self else { return } + self.scheduler.after(self.latencyMs) { [weak self] in + guard let self, !self.closed else { return } + self.onFrame(frame) + } + } + scheduler.after(latencyMs) { [weak self] in + guard let self, !self.closed else { return } + self.status = .open(sinceMs: scheduler.nowMs) + self.onStatus(self.status) + } + } + + public func setHandlers(onFrame: @escaping @MainActor (Bytes) -> Void, onStatus: @escaping @MainActor (CarrierStatus) -> Void) { + self.onFrame = onFrame + self.onStatus = onStatus + } + + public func send(_ frame: Bytes) { + guard status.isOpen, !closed else { return } + scheduler.after(latencyMs) { [weak self] in + guard let self, !self.closed else { return } + self.box.receive(frame) + } + } + + public func wake() {} + + public func close(reason: String) { + if closed { return } + closed = true + box.stop() + status = .closed(reason: reason, retryable: false) + } +} diff --git a/appleApp/FTWKit/Sources/FTWKit/Demo/SimulatedHouse.swift b/appleApp/FTWKit/Sources/FTWKit/Demo/SimulatedHouse.swift new file mode 100644 index 0000000..fa576c4 --- /dev/null +++ b/appleApp/FTWKit/Sources/FTWKit/Demo/SimulatedHouse.swift @@ -0,0 +1,197 @@ +import Foundation + +/// A made-up house for the demo and the tests: solar that follows the sun, +/// a household load with a morning and an evening, a battery run by the +/// chosen mode, and one car charger. +/// +/// Power follows the site convention: positive into the site, PV never +/// positive, and `grid = load + battery + pv` where the load includes the car. +@MainActor +public final class SimulatedHouse { + public let pvPeakW: Double = 6_500 + public let batteryCapacityWh: Double = 13_500 + public let batteryMaxW: Double = 5_000 + public let fuseAmps: Double = 25 + public let timeZone: TimeZone + + public private(set) var socFraction: Double = 0.62 + public private(set) var batteryW: Double = 0 + public private(set) var pvW: Double = 0 + public private(set) var baseLoadW: Double = 0 + public private(set) var gridW: Double = 0 + + public var modeKey = "planner_passive_arbitrage" + + // The car. + public var carPluggedIn = true + public var carSocFraction: Double = 0.41 + public var carSocSource = "inferred" + public var carCapacityWh: Double = 64_000 + public private(set) var evW: Double = 0 + public var manualHoldW: Double? + public var surplusOnly = false + public var boostUntilMs: Double? + public var boostReserve: Double = 0.3 + public var lastBoostStop: String? + public var schedule: (socFraction: Double, minuteUTC: Int, recurring: Bool, days: Int, surplusUnlock: Double)? = (0.8, 5 * 60, true, 0b0011111, 0) + public private(set) var sessionWh: Double = 5_300 + + // Energy since local midnight, integrated. + public private(set) var todayImportWh: Double = 6_100 + public private(set) var todayExportWh: Double = 2_400 + public private(set) var todayPVWh: Double = 11_800 + public private(set) var todayLoadWh: Double = 15_500 + public private(set) var todayChargedWh: Double = 4_200 + public private(set) var todayDischargedWh: Double = 3_900 + private var dayKey = "" + + public init(timeZone: TimeZone = .current) { + self.timeZone = timeZone + } + + /// Hours since local midnight, as a fraction. + func hourOfDay(_ ms: Double) -> Double { + let seconds = ms / 1000 + Double(timeZone.secondsFromGMT(for: Date(timeIntervalSince1970: ms / 1000))) + let day = seconds.truncatingRemainder(dividingBy: 86_400) + return (day < 0 ? day + 86_400 : day) / 3_600 + } + + func localDay(_ ms: Double) -> String { + var cal = Calendar(identifier: .gregorian) + cal.timeZone = timeZone + let c = cal.dateComponents([.year, .month, .day], from: Date(timeIntervalSince1970: ms / 1000)) + return String(format: "%04d-%02d-%02d", c.year ?? 1970, c.month ?? 1, c.day ?? 1) + } + + /// Deterministic wobble in [-1, 1], so two runs of a test agree. + func wobble(_ ms: Double, _ salt: Double) -> Double { + let x = sin(ms / 7_919 + salt) * 43_758.5453 + return (x - x.rounded(.down)) * 2 - 1 + } + + /// Solar at a moment, as a PV reading (never positive). + public func pvAt(_ ms: Double) -> Double { + let h = hourOfDay(ms) + guard h > 5.5, h < 20.5 else { return 0 } + let sun = sin(Double.pi * (h - 5.5) / 15) + let cloud = 0.85 + 0.15 * wobble(ms / 60_000, 3) + return -(pvPeakW * max(0, sun) * max(0, sun) * cloud).rounded() + } + + /// The house without the car. + public func baseLoadAt(_ ms: Double) -> Double { + let h = hourOfDay(ms) + var w = 380.0 + if h >= 6.5 && h < 8.5 { w += 1_100 } + if h >= 17 && h < 19.5 { w += 1_900 } + if h >= 19.5 && h < 23 { w += 600 } + return (w + 120 * wobble(ms / 5_000, 1)).rounded() + } + + /// Import price in öre per kWh, total, for the plan and the price chart. + public func priceAt(_ ms: Double) -> Double { + let h = hourOfDay(ms) + let base = 55 + 60 * max(0, sin(Double.pi * (h - 5) / 6)) + 80 * max(0, sin(Double.pi * (h - 15) / 6)) + let night = h < 5 ? -25.0 : 0 + return (base + night + 6 * wobble(ms / 3_600_000, 7)).rounded() + } + + public var evWatts: Double { evW } + + /// Move the house forward. + public func step(nowMs: Double, dtMs: Double) { + let day = localDay(nowMs) + if day != dayKey { + if !dayKey.isEmpty { + todayImportWh = 0; todayExportWh = 0; todayPVWh = 0 + todayLoadWh = 0; todayChargedWh = 0; todayDischargedWh = 0 + } + dayKey = day + } + + pvW = pvAt(nowMs) + baseLoadW = baseLoadAt(nowMs) + + // The car. + if let until = boostUntilMs, nowMs >= until { + boostUntilMs = nil + lastBoostStop = "expired" + } + if !carPluggedIn || carSocFraction >= 0.999 { + evW = 0 + } else if let hold = manualHoldW { + evW = hold + } else if boostUntilMs != nil { + evW = 7_400 + } else if surplusOnly { + let spare = max(0, -pvW - baseLoadW - 200) + evW = spare >= 1_400 ? min(spare, 11_000).rounded() : 0 + } else if let schedule, carSocFraction < schedule.socFraction, isChargeHour(nowMs) { + evW = 11_000 + } else { + evW = 0 + } + if evW > 0 { + let wh = evW * dtMs / 3_600_000 + sessionWh += wh + carSocFraction = min(1, carSocFraction + wh * 0.92 / carCapacityWh) + } + + // The battery, by mode. + let load = baseLoadW + evW + let net = load + pvW + var want: Double + switch modeKey { + case "idle": want = 0 + case "charge": want = batteryMaxW + case "peak_shaving": want = net > 6_000 ? -(net - 6_000) : (pvW < -500 ? min(batteryMaxW, -net) : 0) + case "planner_arbitrage": + let p = priceAt(nowMs) + want = p < 50 ? batteryMaxW : (p > 120 ? -batteryMaxW : -net) + default: + // Self-consumption: cover the house, soak up the surplus. The + // boost lets the battery feed the car too, down to its reserve. + want = -net + if boostUntilMs == nil { want = -(baseLoadW + pvW) } + if boostUntilMs != nil, socFraction <= boostReserve { + boostUntilMs = nil + lastBoostStop = "battery_reserve_reached" + } + if modeKey == "planner_passive_arbitrage", want < 0, priceAt(nowMs) < 45 { want = 0 } + } + want = max(-batteryMaxW, min(batteryMaxW, want)) + if socFraction >= 0.995, want > 0 { want = 0 } + if socFraction <= 0.05, want < 0 { want = 0 } + batteryW = want.rounded() + let efficiency = batteryW > 0 ? 0.95 : 1 / 0.95 + socFraction = max(0, min(1, socFraction + batteryW * efficiency * dtMs / 3_600_000 / batteryCapacityWh)) + + gridW = (load + batteryW + pvW).rounded() + + let h = dtMs / 3_600_000 + todayLoadWh += load * h + todayPVWh += -pvW * h + if gridW > 0 { todayImportWh += gridW * h } else { todayExportWh += -gridW * h } + if batteryW > 0 { todayChargedWh += batteryW * h } else { todayDischargedWh += -batteryW * h } + } + + /// The cheap night hours before the ready time. + func isChargeHour(_ ms: Double) -> Bool { + let h = hourOfDay(ms) + return h >= 1 && h < 5 + } + + /// A day of the energy ledger, made up from the same model, for days + /// before today. + public func ledger(dayOffset: Int, nowMs: Double) -> (load: Double, pv: Double, imp: Double, exp: Double, charged: Double, discharged: Double) { + if dayOffset == 0 { + return (todayLoadWh, todayPVWh, todayImportWh, todayExportWh, todayChargedWh, todayDischargedWh) + } + let seasonal = 0.8 + 0.3 * wobble(Double(dayOffset) * 86_400_000, 11) + let pv = 26_000 * seasonal + let load = 17_000 + 3_000 * wobble(Double(dayOffset) * 86_400_000, 12) + let charged = min(12_000, pv * 0.35) + let imp = max(1_000, load - pv * 0.55) + return (load, pv, imp, max(0, pv - load * 0.45 - charged), charged, charged * 0.9) + } +} diff --git a/appleApp/FTWKit/Sources/FTWKit/Format/EV.swift b/appleApp/FTWKit/Sources/FTWKit/Format/EV.swift new file mode 100644 index 0000000..826bc58 --- /dev/null +++ b/appleApp/FTWKit/Sources/FTWKit/Format/EV.swift @@ -0,0 +1,405 @@ +import Foundation + +/// One charger as `/api/loadpoints` serves it, read tolerantly, and the +/// sentences that describe it. No sentence claims what the box has not +/// said: a charger that does not know the car's charge is described by what +/// it does know, never by an invented percentage. +public struct Loadpoint: Equatable, Sendable, Identifiable { + public struct Schedule: Equatable, Sendable { + public var socPct: Double + public var timeOfDayMinUTC: Int + public var surplusUnlockPct: Double + public var recurring: Bool + /// 7-bit weekday mask, bit 0 = Monday. Zero means every day. + public var days: Int + } + + public struct Manual: Equatable, Sendable { + public var state: String? + public var requestedA: Double? + public var requestedW: Double? + public var commandedA: Double? + public var chargerReason: String? + public var limitReason: String? + public var chargerUpdatedAtMs: Double? + } + + public struct Charger: Equatable, Sendable { + public var known: Bool + public var available: Bool? + public var updatedAtMs: Double? + public var reason: String? + } + + public struct Window: Equatable, Sendable, Identifiable { + public var fromMs: Double + public var toMs: Double + public var peakW: Double? + public var energyWh: Double? + public var id: Double { fromMs } + } + + public var id: String + public var pluggedIn: Bool + public var powerW: Double + public var socPct: Double? + public var socSource: String + public var vehicleCapacityWh: Double? + public var capacitySource: String + public var socRetention: String + public var chargingDeclined: Bool + public var targetSocPct: Double? + public var sessionWh: Double + public var minChargeW: Double? + public var maxChargeW: Double? + public var phases: Double? + public var voltageV: Double? + public var manualSaveError: Bool + public var manualRestoreUnconfirmed: Bool + public var manualActive: Bool + public var manual: Manual? + public var charger: Charger? + public var commandedW: Double? + public var commandedReason: String + public var commandedKnown: Bool + public var updatedAtMs: Double? + public var gridDeferred: Bool + public var planStartMs: Double? + public var planEndMs: Double? + public var planPending: Bool + public var planOutdated: Bool + /// Whether this box reports its plan with the charger, in one read. + public var inlinePlan: Bool + public var planWindows: [Window] + public var manualChargeW: Double? + public var surplusOnly: Bool + public var boostActive: Bool + public var boostExpiresAtMs: Double? + public var boostReservePct: Double? + public var boostStopReason: String? + public var schedule: Schedule? + + /// A state of charge off the wire, whole percent. The box stores + /// fractions and still reads a legacy percent; zero means unset. + static func pct(_ v: JSON?) -> Double? { + guard let f = v?.number, f > 0 else { return nil } + return (f > 1 ? f : f * 100).rounded() + } + + public init(_ w: JSON) { + let num: (String) -> Double? = { w[$0]?.number } + id = w["id"]?.string ?? "" + pluggedIn = w["plugged_in"]?.bool == true + powerW = num("current_power_w") ?? 0 + let fraction = num("current_soc") + if w["plugged_in"]?.bool == false { + socPct = nil + } else if let f = fraction, f >= 0, f <= 1 { + socPct = (f * 100).rounded() + } else { + socPct = num("current_soc_pct") + } + socSource = w["soc_source"]?.string ?? "" + vehicleCapacityWh = num("vehicle_capacity_wh") + capacitySource = w["capacity_source"]?.string ?? "" + socRetention = w["soc_retention"]?.string ?? "" + chargingDeclined = w["charging_declined"]?.bool == true + targetSocPct = Loadpoint.pct(w["target_soc"]) ?? num("target_soc_pct") + sessionWh = max(0, (num("delivered_wh_session") ?? 0).rounded()) + minChargeW = num("min_charge_w") + maxChargeW = num("max_charge_w") + phases = num("phases") + voltageV = num("voltage_v") + manualActive = w["manual_active"]?.bool == true + manualRestoreUnconfirmed = w["manual_restore_unconfirmed"]?.bool == true + manualSaveError = w["manual_save_error"]?.bool == true + if let m = w["manual"], m.object != nil { + manual = Manual( + state: m["state"]?.string, + requestedA: m["requested_a"]?.number, + requestedW: m["requested_w"]?.number, + commandedA: m["commanded_a"]?.number, + chargerReason: m["charger_reason"]?.string, + limitReason: m["limit_reason"]?.string, + chargerUpdatedAtMs: m["charger_updated_at_ms"]?.number + ) + } + if let c = w["charger"], c.object != nil { + charger = Charger(known: c["known"]?.bool == true, available: c["available"]?.bool, updatedAtMs: c["updated_at_ms"]?.number, reason: c["reason"]?.string) + } + commandedW = num("commanded_w") + commandedReason = w["commanded_reason"]?.string ?? "" + commandedKnown = w["commanded_known"]?.bool == true + updatedAtMs = num("updated_at_ms") + gridDeferred = w["grid_deferred"]?.bool == true + planStartMs = num("plan_next_start_ms") + planEndMs = num("plan_next_end_ms") + planPending = w["plan_pending"]?.bool == true + planOutdated = w["plan_outdated"]?.bool == true + inlinePlan = (w["plan_pending"]?.bool != nil && w["plan_outdated"]?.bool != nil) || w["plan_windows"]?.array != nil + if planPending || planOutdated { + planWindows = [] + } else { + planWindows = (w["plan_windows"]?.array ?? []).compactMap { x in + guard let s = x["start_ms"]?.number, let e = x["end_ms"]?.number, e > s, let wh = x["wh"]?.number, wh >= 0 else { return nil } + return Window(fromMs: s, toMs: e, peakW: nil, energyWh: wh) + } + } + manualChargeW = manualActive ? num("manual_charge_w") : nil + surplusOnly = w["surplus_only"]?.bool == true + let boost = w["battery_boost"] + boostActive = boost?["active"]?.bool == true + boostExpiresAtMs = boostActive ? boost?["expires_at_ms"]?.number : nil + boostReservePct = boostActive ? Loadpoint.pct(boost?["min_battery_soc"]) : nil + if !boostActive, let reason = boost?["stop_reason"]?.string, !reason.isEmpty { + boostStopReason = reason + } else { + boostStopReason = nil + } + if let s = w["schedule"], s.object != nil, let minute = s["time_of_day_min_utc"]?.number, + let socPct = Loadpoint.pct(s["soc"]) ?? s["soc_pct"]?.number, socPct > 0 { + schedule = Schedule( + socPct: socPct, + timeOfDayMinUTC: Int(minute), + surplusUnlockPct: (s["surplus_unlock_bat_soc"]?.number ?? 0) * 100, + recurring: s["recurring"]?.bool == true, + days: Int(s["days"]?.number ?? 0) & 0x7f + ) + } + } +} + +public enum EVText { + public static let dayLabels = ["Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"] + public static let manualSaveErrorText = "This choice is active now, but could not be saved for restart. FTW is retrying." + + /// The weekday mask as a person says it. + public static func days(_ mask: Int) -> String { + let m = mask & 0x7f + if m == 0 || m == 0x7f { return "every day" } + if m == 0b0011111 { return "weekdays" } + if m == 0b1100000 { return "weekends" } + return dayLabels.enumerated().filter { m & (1 << $0.offset) != 0 }.map(\.element).joined(separator: ", ") + } + + // MARK: Clock conversions + + /// UTC minutes of the day on the local clock, today. + public static func localTime(minuteUTC: Int, at: Date = Date(), calendar: Calendar = .current) -> (hour: Int, minute: Int) { + var utc = Calendar(identifier: .gregorian) + utc.timeZone = TimeZone(identifier: "UTC")! + var comps = utc.dateComponents([.year, .month, .day], from: at) + comps.hour = minuteUTC / 60 + comps.minute = minuteUTC % 60 + let date = utc.date(from: comps) ?? at + let local = calendar.dateComponents([.hour, .minute], from: date) + return (local.hour ?? 0, local.minute ?? 0) + } + + /// A local hour and minute back to UTC minutes of the day, exact for + /// today's offset: the box's own page does the same. + public static func minuteUTC(hour: Int, minute: Int, at: Date = Date(), calendar: Calendar = .current) -> Int? { + guard (0...23).contains(hour), (0...59).contains(minute) else { return nil } + var comps = calendar.dateComponents([.year, .month, .day], from: at) + comps.hour = hour + comps.minute = minute + guard let date = calendar.date(from: comps) else { return nil } + var utc = Calendar(identifier: .gregorian) + utc.timeZone = TimeZone(identifier: "UTC")! + let u = utc.dateComponents([.hour, .minute], from: date) + return (u.hour ?? 0) * 60 + (u.minute ?? 0) + } + + public static func clock(_ ms: Double, calendar: Calendar = .current) -> String { + let c = calendar.dateComponents([.hour, .minute], from: Date(timeIntervalSince1970: ms / 1000)) + return String(format: "%02d:%02d", c.hour ?? 0, c.minute ?? 0) + } + + // MARK: Sentences + + /// What the charger is doing now. Power leads when there is any. + public static func status(_ lp: Loadpoint, canControl: Bool = true) -> String { + if lp.manualRestoreUnconfirmed { + return (canControl ? "Confirm how to continue charging." : "An owner needs to confirm how charging should continue.") + " FTW could not confirm the charger or connection." + } + if let c = lp.charger, c.available != true { + return c.known ? "Charger status is out of date. FTW cannot confirm whether the car is charging." : "Waiting for the charger’s first status report." + } + if !lp.pluggedIn { return "Not plugged in" } + if lp.manualActive { return manualStatus(lp) } + if lp.powerW >= 100 { return "Charging at \(PowerFormat.text(lp.powerW))" } + if lp.chargingDeclined { return "The car stopped asking for charge. Check its charge limit or schedule. This does not confirm the battery is full." } + if lp.commandedKnown, lp.commandedReason == "site_meter_stale" { return "Paused for safety: house power readings are out of date. Charging resumes when readings return." } + if lp.commandedKnown, lp.commandedW == 0, ["fuse_cooldown", "fuse_limit"].contains(lp.commandedReason) { return "Paused: main-fuse protection. Charging resumes on its own." } + if lp.commandedKnown, let w = lp.commandedW, w > 0 { + return "FTW requests \(PowerFormat.text(w)). Waiting for the car to draw power." + (lp.charger?.reason.map { " Charger reports: \($0)." } ?? "") + } + return "Plugged in — not charging right now" + } + + /// A zero hold is a pause; older boxes omit their zero setpoint. + public static func isPaused(_ lp: Loadpoint) -> Bool { + guard !lp.manualRestoreUnconfirmed, lp.manualActive else { return false } + if lp.manualChargeW == 0 { return true } + if lp.manual?.state == "paused" || lp.manual?.state == "pausing" { return true } + return lp.manualChargeW == nil && (lp.manual?.requestedW == 0 || lp.manual?.requestedA == 0) + } + + /// The hold is intent; only a fresh charger reading proves charging. + public static func manualStatus(_ lp: Loadpoint) -> String { + let m = lp.manual + let request = m?.requestedA.map { "\(Int($0.rounded())) A" } ?? "your charge request" + let limit = m?.commandedA.map { "\(Int($0.rounded())) A" } ?? "the requested current" + let reason = m?.chargerReason.flatMap { $0.isEmpty ? nil : " Charger reports: \($0)." } ?? "" + let flowing = lp.powerW >= 100 ? PowerFormat.text(lp.powerW) : nil + switch m?.state { + case "unavailable": return "Charger status is out of date. FTW cannot confirm whether the car is charging." + case "pausing": return "Pause requested. " + (flowing.map { "\($0) is still flowing. " } ?? "") + "Waiting for the charger to stop." + case "paused": return "Paused by you. Charging stays off until you resume the plan, choose Charge now, or unplug." + case "charging": return lp.powerW > 0 ? "Charging at \(PowerFormat.text(lp.powerW)). \(request) requested." : "The charger reports charging. Waiting for a power reading." + case "sent": return "FTW received \(request). Waiting for the charger to confirm the new limit." + (flowing.map { " Still charging at \($0)." } ?? "") + case "accepted": return "Charger reports a \(limit) limit. Waiting for the car to start drawing…\(reason)" + case "not_drawing": return "Charger offers \(limit) but the car is not drawing.\(reason.isEmpty ? " Check the car’s charge limit or schedule." : reason)" + case "stalled": + if isPaused(lp) { return "The charger has not stopped after your pause request. Check the charger’s app." } + return "The charger has not acted on \(request)." + (flowing.map { " Still charging at \($0)." } ?? "") + (reason.isEmpty ? " Check the charger and the car’s charge limit or schedule." : reason) + case "limited": + switch m?.limitReason { + case "charger_limit": return "The charger limits this request to \(limit) (\(request) requested)." + case "site_meter_stale": return "Paused for safety: house power readings are out of date. Charging resumes when readings return." + case "fuse_cooldown": return "Paused: main-fuse protection. Charging resumes on its own." + default: return "Main fuse limits this charge to \(limit) right now (\(request) requested)." + } + default: + if lp.powerW >= 100 { return "Charging at \(PowerFormat.text(lp.powerW))" } + return "Manual charge requested. Waiting for charger status." + } + } + + public static func plan(_ lp: Loadpoint, nowMs: Double, canControl: Bool = true, calendar: Calendar = .current) -> String? { + if lp.planPending { return "Updating the charging plan…" } + if lp.planOutdated { return "Charging times are unavailable. Your settings are saved." } + if !lp.pluggedIn || lp.manualActive || lp.manualRestoreUnconfirmed || lp.chargingDeclined { return nil } + if let c = lp.charger, c.available != true { return nil } + if lp.gridDeferred, lp.schedule != nil { return "Waiting for tomorrow’s electricity prices. Solar surplus can charge the car meanwhile." } + if let s = lp.planStartMs, let e = lp.planEndMs, s > 0, e > nowMs { + return "Charging planned \(clock(s, calendar: calendar))–\(clock(e, calendar: calendar))." + } + if lp.surplusOnly { return "Solar only: charging waits for spare solar power." } + if lp.schedule == nil, lp.powerW < 100 { + return canControl ? "No charging plan yet. Set a ready time, or choose Charge now." : "No charging plan yet. Ask an owner to set a ready time or start charging." + } + if lp.schedule != nil, lp.powerW < 100 { + return canControl ? "No charge window yet for this goal. Choose Charge now if you need to charge immediately." : "No charge window yet for this goal. An owner can start charging now." + } + return nil + } + + /// "85 % Ready by 07:00 · weekdays". Nil when the box has no schedule: + /// an app that cannot read one cannot claim its absence. + public static func schedule(_ lp: Loadpoint, at: Date = Date(), calendar: Calendar = .current) -> String? { + guard let s = lp.schedule else { return nil } + let t = localTime(minuteUTC: s.timeOfDayMinUTC, at: at, calendar: calendar) + let when = String(format: "%02d:%02d", t.hour, t.minute) + return "\(Int(s.socPct.rounded())) % Ready by \(when) · \(s.recurring ? days(s.days) : "once")" + } + + public static func session(_ lp: Loadpoint) -> String? { + guard lp.pluggedIn, lp.sessionWh >= 50 else { return nil } + let kwh = lp.sessionWh / 1000 + return "\(kwh >= 10 ? String(Int(kwh.rounded())) : PowerFormat.fixed(kwh, 1)) kWh this session" + } + + // MARK: The car's level + + public static let socDefaultPct: Double = 50 + + public static func socSource(_ lp: Loadpoint) -> String { + let retention: String + switch lp.socRetention { + case "session": retention = " FTW keeps this level for the same charging session, including after a box restart." + case "error": retention = " This level could not be saved for a box restart. Enter it again before relying on the plan after restarting." + default: retention = " This level must be entered again after a box restart." + } + switch lp.socSource { + case "assumed": return "Battery level needs confirmation. The plan currently assumes \(Int(lp.socPct ?? socDefaultPct)) %. Drag to match the car." + retention + case "vehicle": return "Reported by the car. Drag only to correct drift." + case "completed": return "The car stopped asking for charge. Its actual battery level is not confirmed. Drag to match the car." + default: return "Estimated from energy delivered. Drag to the real value and the plan follows." + retention + } + } + + // MARK: Charge now, in amps + + public struct Current: Equatable, Sendable { + public let minA: Int + public let maxA: Int + public let wattsPerAmp: Double + public let phases: Int + } + + /// The slider's range, with the box page's fallbacks: three phases at + /// 230 V, 6 to 16 A when no floor or ceiling was reported. + public static func current(_ lp: Loadpoint) -> Current { + let phases = (lp.phases ?? 0) > 0 ? lp.phases! : 3 + let volts = (lp.voltageV ?? 0) > 0 ? lp.voltageV! : 230 + let perAmp = phases * volts + func toA(_ w: Double?) -> Int { guard let w, w > 0 else { return 0 }; return Int((w / perAmp).rounded()) } + let minA = max(1, toA(lp.minChargeW) == 0 ? 6 : toA(lp.minChargeW)) + var maxA = toA(lp.maxChargeW) == 0 ? 16 : toA(lp.maxChargeW) + if maxA <= minA { maxA = minA + 1 } + return Current(minA: minA, maxA: maxA, wattsPerAmp: perAmp, phases: Int(phases)) + } + + /// Watts for a current, never above the ceiling the box declared. + public static func watts(_ lp: Loadpoint, amps: Int) -> Double { + let w = (Double(amps) * current(lp).wattsPerAmp).rounded() + if let cap = lp.maxChargeW, cap > 0 { return min(w, cap) } + return w + } + + public static func amps(_ lp: Loadpoint, watts: Double) -> Int { + Int((watts / current(lp).wattsPerAmp).rounded()) + } + + /// "16 A · 11.0 kW". + public static func readout(_ lp: Loadpoint, amps: Int) -> String { + "\(amps) A · \(PowerFormat.fixed(watts(lp, amps: amps) / 1000, 1)) kW" + } + + // MARK: Battery boost + + public static let boostReserveDefaultPct = 30 + public static let boostReserveMinPct = 5 + public static let boostDurations: [(seconds: Int, label: String)] = [(1800, "30 min"), (3600, "1 h"), (7200, "2 h"), (14400, "4 h")] + public static let boostDurationDefault = 3600 + + static let boostStop: [String: String] = [ + "cancelled": "it was stopped by hand", + "expired": "its time ran out", + "vehicle_unplugged": "the car was unplugged", + "ev_target_reached": "the car reached its target", + "departure_reached": "the departure time came", + "operator_hold": "a manual charge took over", + "surplus_only": "the charger went back to spare solar only", + "site_safety_block": "the site meter went quiet", + "loadpoint_driver_unavailable": "your box lost touch with the charger", + "battery_unavailable": "your box lost touch with the house battery", + "battery_reserve_reached": "the house battery reached its reserve", + "battery_hold": "the house battery was held for something else", + "core_mode": "the site mode does not allow it", + "fuse_safety_block": "the fuse limit stepped in", + "restart_lease_invalid": "your box restarted and would not resume it", + ] + + public static func boostActive(_ lp: Loadpoint, calendar: Calendar = .current) -> String { + let reserve = lp.boostReservePct.map { " down to \(Int($0)) %" } ?? "" + let until = lp.boostExpiresAtMs.map { " until \(clock($0, calendar: calendar))" } ?? "" + return "Battery boost is on — the house battery is helping the car\(reserve)\(until)." + } + + public static func boostStopped(_ lp: Loadpoint) -> String? { + guard !lp.boostActive, let reason = lp.boostStopReason else { return nil } + return "The last boost ended because \(boostStop[reason] ?? "your box stopped it")." + } +} diff --git a/appleApp/FTWKit/Sources/FTWKit/Format/Explanation.swift b/appleApp/FTWKit/Sources/FTWKit/Format/Explanation.swift new file mode 100644 index 0000000..a8e175f --- /dev/null +++ b/appleApp/FTWKit/Sources/FTWKit/Format/Explanation.swift @@ -0,0 +1,66 @@ +import Foundation + +/// Why, not just what. +/// +/// "Battery: -4.2 kW" is a number. "The battery is covering the house, so +/// nothing is coming from the grid" is an answer, and a glance has room for +/// one sentence. Where the readings cannot tell, this says less rather than +/// guessing: a confident wrong sentence is worse than a plain one. +public enum Explanation { + public enum Situation: String, Sendable { + case noData, exportingSurplus, chargingFromSurplus, batteryCovering, batteryShaving + case solarCovering, solarPartial, importing, dispatchBlocked + } + + public struct Result: Equatable, Sendable { + public let situation: Situation + public let headline: String + } + + static func kw(_ watts: Double) -> String { PowerFormat.text(abs(watts)) } + + public static func explain(fields: [Int: Double], dispatchBlockedBy: [String], ceilingW: Double?) -> Result { + let noise = PowerFormat.noiseW + guard let grid = fields[Contract.FID.gridW], let load = fields[Contract.FID.loadW] else { + return Result(situation: .noData, headline: "Waiting for the first reading.") + } + // The box's own safety rule outranks everything else it might do. + if !dispatchBlockedBy.isEmpty { + return Result(situation: .dispatchBlocked, headline: "Control is paused because a meter stopped reporting. Your home is running normally on grid power.") + } + // PV is never positive: -3000 means making 3 kW. + let generating = max(0, -(fields[Contract.FID.pvW] ?? 0)) + let bat = fields[Contract.FID.batteryW] ?? 0 + let ev = fields[Contract.FID.evW] ?? 0 + let carCharging = ev > noise + let covered = carCharging ? "the house and the car" : "the house" + + if grid < -noise { + return Result(situation: .exportingSurplus, headline: "Solar is covering the house and sending \(kw(grid)) back to the grid.") + } + if bat > noise, generating > noise, grid < noise { + return Result(situation: .chargingFromSurplus, headline: "Spare solar is charging the battery at \(kw(bat)).") + } + if bat < -noise { + if let ceilingW, grid > noise { + return Result(situation: .batteryShaving, headline: "The battery is supplying \(kw(bat)) to keep grid import below \(kw(ceilingW)).") + } + if grid < noise { + return Result(situation: .batteryCovering, headline: "The battery is covering \(covered), so nothing is coming from the grid.") + } + return Result(situation: .batteryShaving, headline: carCharging + ? "The car is charging at \(kw(ev)), with the battery supplying \(kw(bat))." + : "The battery is supplying \(kw(bat)), with \(kw(grid)) from the grid.") + } + if generating > noise { + if grid < noise { + return Result(situation: .solarCovering, headline: "Solar is covering everything the house is using.") + } + return Result(situation: .solarPartial, headline: "Solar is covering \(kw(generating)) of the \(kw(load)) the house is using.") + } + if grid > noise { + return Result(situation: .importing, headline: "The house is drawing \(kw(grid)) from the grid.") + } + return Result(situation: .importing, headline: "The house is drawing almost nothing right now.") + } +} diff --git a/appleApp/FTWKit/Sources/FTWKit/Format/Flow.swift b/appleApp/FTWKit/Sources/FTWKit/Format/Flow.swift new file mode 100644 index 0000000..8587db6 --- /dev/null +++ b/appleApp/FTWKit/Sources/FTWKit/Format/Flow.swift @@ -0,0 +1,181 @@ +import Foundation + +/// From a site's readings to the energy diagram's nodes: the same corners, +/// the same colours by role and direction, the same rules as the box's own +/// dashboard, so a phone and the box page tell one story. +public enum Flow { + /// Below this a node is idle: the box page's FLOW_IDLE_W, the same + /// number as the site's grid tolerance. + public static let idleW: Double = 42 + + public enum Role: String, Sendable { case grid, pv, battery, ev, load } + public enum Corner: Sendable { case topLeft, topRight, bottomLeft, bottomRight } + + /// Colour meaning, not colour: the UI maps these to its palette. + public enum Tone: Sendable { case muted, importing, exporting, solar, battery, charging, discharging, ev, house } + + public struct Node: Equatable, Sendable, Identifiable { + public var id: String + public var role: Role + public var corner: Corner + public var title: String + public var name: String? + /// Magnitude in kW, never signed for display. + public var kw: Double + /// Whether power flows toward the house. + public var toHub: Bool + public var tone: Tone + public var sub: String + public var socPct: Double? + public var dailyParts: [(text: String, tone: Tone)] + public var tappable: Bool + + public static func == (a: Node, b: Node) -> Bool { + a.id == b.id && a.kw == b.kw && a.toHub == b.toHub && a.sub == b.sub && a.socPct == b.socPct && a.dailyParts.map(\.text) == b.dailyParts.map(\.text) + } + } + + public struct Readings: Equatable, Sendable { + public var loadKw: Double + public var nodes: [Node] + public var selfPoweredPctToday: Double? + } + + static func idle(_ w: Double) -> Bool { abs(w) <= idleW } + + public static func tone(_ role: Role, _ watts: Double?) -> Tone { + guard let w = watts else { + switch role { + case .grid, .pv: return .muted + case .battery: return .battery + case .ev: return .ev + case .load: return .house + } + } + switch role { + case .grid: return idle(w) ? .muted : (w >= 0 ? .importing : .exporting) + case .pv: return idle(w) ? .muted : .solar + case .battery: return idle(w) ? .battery : (w >= 0 ? .charging : .discharging) + case .ev: return idle(w) ? .ev : .charging + case .load: return .house + } + } + + /// Nodes from the frozen fields on the 1 Hz stream. A field that never + /// arrived is no node, except the grid: a site that cannot see its own + /// meter is a gap the owner should see. + public static func readings(fields: [Int: Double]) -> Readings { + var nodes = [Node]() + if let g = fields[Contract.FID.gridW] { + nodes.append(Node(id: "grid", role: .grid, corner: .bottomLeft, title: "GRID", kw: abs(g) / 1000, toHub: g >= 0, tone: tone(.grid, g), sub: idle(g) ? "balanced" : (g >= 0 ? "importing" : "exporting"), dailyParts: [], tappable: true)) + } else { + nodes.append(Node(id: "grid", role: .grid, corner: .bottomLeft, title: "GRID", kw: 0, toHub: true, tone: .muted, sub: "no data", dailyParts: [], tappable: false)) + } + if let p = fields[Contract.FID.pvW] { + nodes.append(Node(id: "pv", role: .pv, corner: .topLeft, title: "SOLAR", kw: -p / 1000, toHub: true, tone: tone(.pv, p), sub: "", dailyParts: [], tappable: true)) + } + if let b = fields[Contract.FID.batteryW] { + nodes.append(Node(id: "battery", role: .battery, corner: .topRight, title: "BATTERY", kw: abs(b) / 1000, toHub: b < 0, tone: tone(.battery, b), sub: idle(b) ? "idle" : (b >= 0 ? "charging" : "discharging"), socPct: fields[Contract.FID.batterySoc].map { ($0 / 10).rounded() }, dailyParts: [], tappable: true)) + } + // No field, no node: an invented idle charger would misstate hardware. + if let e = fields[Contract.FID.evW] { + nodes.append(Node(id: "ev", role: .ev, corner: .bottomRight, title: "EV CHARGER", kw: e / 1000, toHub: false, tone: tone(.ev, e), sub: idle(e) ? "idle" : "charging", dailyParts: [], tappable: true)) + } + return Readings(loadKw: (fields[Contract.FID.loadW] ?? 0) / 1000, nodes: nodes, selfPoweredPctToday: nil) + } + + /// Nodes from GET /api/status: per-driver bubbles and energy today, the + /// dashboard's own document. + public static func readings(status: JSON) -> Readings { + var nodes = [Node]() + let today = status["energy"]?["today"] + func kwh(_ key: String) -> Double { (today?[key]?.number ?? 0) / 1000 } + let imp = kwh("import_wh"), exp = kwh("export_wh"), pvTotal = kwh("pv_wh"), loadTotal = kwh("load_wh") + let charged = kwh("bat_charged_wh"), discharged = kwh("bat_discharged_wh") + let gridDaily: [(String, Tone)] = [("↓ \(EnergyFormat.short(imp))", .importing), ("↑ \(EnergyFormat.short(exp))", .exporting)] + let batDaily: [(String, Tone)] = [("↑ \(EnergyFormat.short(charged))", .charging), ("↓ \(EnergyFormat.short(discharged))", .discharging)] + + if let g = status["grid_w"]?.number { + nodes.append(Node(id: "grid", role: .grid, corner: .bottomLeft, title: "GRID", kw: abs(g) / 1000, toHub: g >= 0, tone: tone(.grid, g), sub: idle(g) ? "balanced" : (g >= 0 ? "importing" : "exporting"), dailyParts: gridDaily.map { (text: $0.0, tone: $0.1) }, tappable: true)) + } else { + nodes.append(Node(id: "grid", role: .grid, corner: .bottomLeft, title: "GRID", kw: 0, toHub: true, tone: .muted, sub: "no data", dailyParts: [], tappable: false)) + } + + for (name, d) in (status["drivers"]?.object ?? []).sorted(by: { $0.0 < $1.0 }) { + let st = d["status"]?.string ?? "" + guard st != "offline", st != "disabled", d["not_running"]?.bool != true else { continue } + if let p = d["pv_w"]?.number { + nodes.append(Node(id: "pv-\(name)", role: .pv, corner: .topLeft, title: "SOLAR", name: name, kw: -p / 1000, toHub: true, tone: tone(.pv, p), sub: "", dailyParts: [(text: "\(EnergyFormat.short(pvTotal)) kWh", tone: .solar)], tappable: true)) + } + if let b = d["bat_w"]?.number { + let observe = d["observe_only"]?.bool == true + nodes.append(Node(id: "bat-\(name)", role: .battery, corner: .topRight, title: "BATTERY", name: name, kw: abs(b) / 1000, toHub: b < 0, tone: tone(.battery, b), sub: observe ? "observe only" : (idle(b) ? "idle" : (b >= 0 ? "charging" : "discharging")), socPct: d["bat_soc"]?.number.map { ($0 * 100).rounded() }, dailyParts: batDaily.map { (text: $0.0, tone: $0.1) }, tappable: !observe)) + } + if let e = d["ev_w"]?.number { + nodes.append(Node(id: "ev-\(name)", role: .ev, corner: .bottomRight, title: "EV CHARGER", name: name, kw: abs(e) / 1000, toHub: false, tone: tone(.ev, e), sub: idle(e) ? "idle" : "charging", dailyParts: [], tappable: true)) + } + } + let selfPowered: Double? = loadTotal > 0.001 ? max(0, min(100, (1 - imp / loadTotal) * 100)) : nil + return Readings(loadKw: (status["load_w"]?.number ?? 0) / 1000, nodes: nodes, selfPoweredPctToday: selfPowered) + } + + /// Charger watts the box's HTTP API knows, summed. + public static func loadpointChargeW(_ points: [Loadpoint]) -> Double { + points.reduce(0) { $0 + ($1.powerW > idleW ? $1.powerW : 0) } + } + + /// Put the car on its own node when the 1 Hz stream folded it into the + /// house. A box that already sends field 10 is left alone. + public static func withLoadpointEV(_ fields: [Int: Double], evW: Double) -> [Int: Double] { + guard evW > idleW else { return fields } + if let wire = fields[Contract.FID.evW], abs(wire) > idleW { return fields } + var out = fields + out[Contract.FID.evW] = evW.rounded() + out[Contract.FID.loadW] = max(0, (fields[Contract.FID.loadW] ?? 0) - evW) + return out + } + + // MARK: The fuse + + public struct Phase: Equatable, Sendable, Identifiable { + public let label: String + public let amps: Double + public let watts: Double + public let pct: Double + public let exporting: Bool + public var id: String { label } + } + + public struct Fuse: Equatable, Sendable { + public let maxAmps: Double + public let phases: [Phase] + public let fallback: (amps: Double, pct: Double)? + + public static func == (a: Fuse, b: Fuse) -> Bool { + a.maxAmps == b.maxAmps && a.phases == b.phases && a.fallback?.amps == b.fallback?.amps + } + } + + /// Per phase when the meter reports amps; otherwise one bar from grid, + /// PV and battery throughput, as the box page does. + public static func fuse(status: JSON) -> Fuse? { + guard let f = status["fuse"], let maxAmps = f["max_amps"]?.number, maxAmps > 0 else { return nil } + let n = max(1, min(3, Int((f["phases"]?.number ?? 3).rounded()))) + let voltage = f["voltage"]?.number ?? 230 + let amps = (status["phase_amps"]?.array ?? []).map { $0.number ?? 0 } + let watts = (status["phase_powers"]?.array ?? []).map { $0.number ?? 0 } + if !amps.isEmpty { + let phases = (0.. Phase in + let a = i < amps.count ? amps[i] : 0 + return Phase(label: "L\(i + 1)", amps: a, watts: i < watts.count ? watts[i] : 0, pct: min(100, abs(a) / maxAmps * 100), exporting: a < -0.1) + } + return Fuse(maxAmps: maxAmps, phases: phases, fallback: nil) + } + let grid = abs(status["grid_w"]?.number ?? 0) + let pv = abs(status["pv_w"]?.number ?? 0) + let bat = status["bat_w"]?.number ?? 0 + let throughput = max(grid, pv + (bat < 0 ? -bat : 0)) + let a = throughput / voltage / Double(n) + return Fuse(maxAmps: maxAmps, phases: [], fallback: (a, min(100, a / maxAmps * 100))) + } +} diff --git a/appleApp/FTWKit/Sources/FTWKit/Format/Freshness.swift b/appleApp/FTWKit/Sources/FTWKit/Format/Freshness.swift new file mode 100644 index 0000000..dda66dd --- /dev/null +++ b/appleApp/FTWKit/Sources/FTWKit/Format/Freshness.swift @@ -0,0 +1,104 @@ +import Foundation + +/// The freshness band in words: the web app's FreshnessBand. +/// +/// Two facts that never collapse into one: how frames reach this phone +/// (`carrier`) and whether the box's own devices are answering +/// (`srcState`). The band claims live only while readings arrive now, and +/// never claims a problem it has not confirmed: connecting while the cache +/// is on screen is normal, not a fault. +public enum Freshness { + public enum Tone: Sendable { case live, stale, reaching, lost } + + public struct Band: Equatable, Sendable { + public let tone: Tone + public let message: String + /// Seconds waited, or the boot percentage. Changes every second. + public let wait: String? + /// The age of what is on screen, as its own field. "—" means the box + /// cannot place the reading at all, which happens after a restart. + public let age: String? + } + + public static func band( + carrier: CarrierKind, + transport: CarrierKind, + srcState: SourceState, + ageMs: Double?, + phase: SessionPhase, + waitMs: Double, + bootPct: Int?, + noCarrier: Bool + ) -> Band { + let reaching = !noCarrier && [.idle, .handshaking, .subscribing, .failed, .booting].contains(phase) + let connected = carrier == .relay || carrier == .webrtc + + let tone: Tone = connected ? (srcState == .live ? .live : .stale) : (reaching ? .reaching : .lost) + + let message: String + if connected { + let liveWhere = carrier == .webrtc ? "Live at home" : "Live via encrypted relay" + let link = carrier == .webrtc ? "Home link connected" : "Encrypted relay connected" + switch srcState { + case .live: message = liveWhere + case .never: message = "\(link) · no reading yet" + case .down: message = "\(link) · a device stopped responding" + default: message = "\(link) · readings" + } + } else if phase == .terminated { + // The box is reachable; it told this phone to leave. + message = "Access ended" + } else if phase == .booting { + message = "Your box is starting" + } else if reaching { + if phase == .failed { + message = "Reconnecting to your box" + } else if phase == .subscribing { + message = "Waiting for live readings" + } else if phase == .handshaking, transport == .relay { + message = "Securing encrypted relay" + } else if phase == .handshaking, transport == .webrtc { + message = "Securing home link" + } else { + message = "Connecting to your box" + } + } else { + message = "Can't reach your box" + } + + var wait: String? + if reaching { + if phase == .booting, let bootPct { + wait = "\(bootPct)%" + } else { + wait = "\(max(0, Int(waitMs / 1000)))s" + } + } + + var age: String? + if !(connected && (srcState == .live || srcState == .never)) { + if let ageMs { + age = ageMs.isFinite ? PowerFormat.age(ageMs) : nil + } else { + age = "—" + } + } + return Band(tone: tone, message: message, wait: wait, age: age) + } +} + +extension SiteModel { + /// The band for this home, read the way the web app's shell reads it. + public func freshness(noCarrier: Bool) -> Freshness.Band { + Freshness.band( + carrier: carrier, + transport: session.carrier, + srcState: srcState, + ageMs: ageMs, + phase: session.phase, + waitMs: connectionWaitMs, + bootPct: session.boot?.pct, + noCarrier: noCarrier + ) + } +} diff --git a/appleApp/FTWKit/Sources/FTWKit/Format/PlanText.swift b/appleApp/FTWKit/Sources/FTWKit/Format/PlanText.swift new file mode 100644 index 0000000..cbc7a5d --- /dev/null +++ b/appleApp/FTWKit/Sources/FTWKit/Format/PlanText.swift @@ -0,0 +1,296 @@ +import Foundation + +/// A plan in sentences. The box sends reason codes; every word is decided +/// here. A plan is intent, not prophecy, so the copy says what the box means +/// to do and never promises it. +public enum PlanText { + public enum Action: Equatable, Sendable { case charge, discharge, idle } + + static let reasons: [String: String] = [ + "cheap_import": "Power is cheap", + "expensive_import": "Power is expensive", + "solar_surplus": "Spare solar", + "peak_shaving": "Holding the grid limit", + "reserve_held": "Keeping a reserve", + "export_paid": "Sending to the grid", + "idle": "Nothing scheduled", + ] + + /// A reason this app has not heard of is still the box's reason. + public static func reason(_ code: String) -> String { + reasons[code] ?? code.replacingOccurrences(of: "_", with: " ").capitalized + } + + public static func action(_ slot: PlanSlot) -> Action { + if slot.batteryW > 50 { return .charge } + if slot.batteryW < -50 { return .discharge } + return .idle + } + + /// Mode wording is the box's, from its own catalogue, so the dashboard + /// and this app never give one setting two names. + public static func label(_ mode: ModeInfo) -> String { mode.label } + public static func help(_ mode: ModeInfo) -> String { mode.tooltip } + + public struct Headline: Equatable, Sendable { + public let text: String + public let slotIndex: Int? + } + + /// One sentence about what happens next, which is what a plan is for. + public static func headline(_ plan: Plan?, nowMs: Double) -> Headline { + guard let plan else { return Headline(text: "No plan yet.", slotIndex: nil) } + if plan.stale { + return Headline(text: "Your box couldn't plan ahead just now, so it's running on safe defaults.", slotIndex: nil) + } + guard let current = plan.slots.firstIndex(where: { nowMs >= $0.startMs && nowMs < $0.startMs + $0.durationMs }) else { + return Headline(text: "No plan for right now.", slotIndex: nil) + } + let now = plan.slots[current] + let nowAction = action(now) + var next = current + 1 + while next < plan.slots.count, action(plan.slots[next]) == nowAction { next += 1 } + if next >= plan.slots.count { + return Headline(text: describe(now, later: false), slotIndex: current) + } + let change = plan.slots[next] + return Headline(text: "\(describe(now, later: false)) Then \(describe(change, later: true)) \(inWords(change.startMs - nowMs)).", slotIndex: current) + } + + public struct Brief: Equatable, Sendable { + public enum Tone: Sendable { case active, warn, idle } + public let stateLabel: String + public let tone: Tone + public let action: String + public let time: String? + public let reason: String? + public let constraint: String + } + + /// The box page's overview card: what, until when, why. + public static func brief(_ plan: Plan?, nowMs: Double, mode: String?, dispatchBlockedBy: [String], clock: (Double) -> String) -> Brief { + let planner = mode?.hasPrefix("planner_") ?? false + guard let plan else { + return Brief( + stateLabel: mode != nil && !planner ? "Manual" : "Checking…", + tone: .idle, + action: planner || mode == nil ? "Reading the current plan" : "Manual control is active", + time: nil, + reason: planner || mode == nil ? nil : "Planning is not controlling the battery", + constraint: constraint(nil, dispatchBlockedBy, nil) + ) + } + if plan.stale { + return Brief(stateLabel: "Fallback active", tone: .warn, action: "Your box couldn't plan ahead just now", time: nil, reason: "It's running on safe defaults", constraint: constraint(plan, dispatchBlockedBy, nil)) + } + let (label, tone) = state(mode, hasPlan: true) + guard let current = plan.slots.firstIndex(where: { nowMs >= $0.startMs && nowMs < $0.startMs + $0.durationMs }) else { + let (l, t) = state(mode, hasPlan: false) + return Brief(stateLabel: l, tone: t, action: "No plan for right now", time: nil, reason: nil, constraint: constraint(plan, dispatchBlockedBy, nil)) + } + let now = plan.slots[current] + let nowAction = action(now) + var runEnd = current + while runEnd + 1 < plan.slots.count, action(plan.slots[runEnd + 1]) == nowAction { runEnd += 1 } + let untilMs = plan.slots[runEnd].startMs + plan.slots[runEnd].durationMs + + var shown = now + var time: String? = "Now, until \(clock(untilMs))" + if nowAction == .idle { + if let future = plan.slots.enumerated().first(where: { $0.offset > current && action($0.element) != .idle })?.element { + shown = future + time = "At \(clock(future.startMs))" + } else { + time = nil + } + } + return Brief(stateLabel: label, tone: tone, action: actionLabel(shown), time: time, reason: reason(shown.reason), constraint: constraint(plan, dispatchBlockedBy, shown)) + } + + static func state(_ mode: String?, hasPlan: Bool) -> (String, Brief.Tone) { + if let mode, mode.hasPrefix("planner_") { + return hasPlan ? ("Plan active", .active) : ("Checking…", .idle) + } + if mode != nil { return ("Manual", .idle) } + return (hasPlan ? "Plan ready" : "Checking…", .idle) + } + + static func actionLabel(_ slot: PlanSlot) -> String { + let amount = PowerFormat.text(slot.batteryW) + switch action(slot) { + case .idle: return "Keep the battery steady" + case .charge: return "Charge battery at \(amount)" + case .discharge: return "Use battery at \(amount)" + } + } + + static func constraint(_ plan: Plan?, _ blocked: [String], _ slot: PlanSlot?) -> String { + if !blocked.isEmpty { return "Control is paused because a meter stopped reporting." } + if plan?.stale == true { return "The schedule is old, so FTW is using safe live balancing." } + if let slot, slot.reason == "peak_shaving", let cap = plan?.ceilingW { + return "Holding the grid limit at \(PowerFormat.text(cap))." + } + return "No active safety adjustment." + } + + static func describe(_ slot: PlanSlot, later: Bool) -> String { + let amount = PowerFormat.text(slot.batteryW) + switch action(slot) { + case .idle: + return later ? "it rests" : "The battery is resting — \(reason(slot.reason).lowercased())." + case .charge: + return later ? "it charges at \(amount)" : "The battery is charging at \(amount) — \(reason(slot.reason).lowercased())." + case .discharge: + return later ? "it covers the house at \(amount)" : "The battery is covering the house at \(amount) — \(reason(slot.reason).lowercased())." + } + } + + /// Coarse on purpose: a plan is intent that will be revised. + static func inWords(_ ms: Double) -> String { + let minutes = Int((ms / 60_000).rounded()) + if minutes < 1 { return "in a moment" } + if minutes < 25 { return "in \(minutes) min" } + if minutes < 50 { return "in about half an hour" } + let hours = Int((Double(minutes) / 60).rounded()) + if hours <= 1 { return "in about an hour" } + if hours < 10 { return "in about \(hours) hours" } + return "later today" + } +} + +/// What to call the number on a price, from the box's price-units.js. Every +/// price is minor units per kWh; only the label and the decimals differ. +public enum PriceUnits { + public struct Unit: Sendable { + public let label: String + public let perKwh: String + public let scale: Double + public let decimals: Int + } + + static let units: [String: Unit] = [ + "SEK": Unit(label: "öre", perKwh: "öre/kWh", scale: 1, decimals: 1), + "NOK": Unit(label: "øre", perKwh: "øre/kWh", scale: 1, decimals: 1), + "DKK": Unit(label: "øre", perKwh: "øre/kWh", scale: 1, decimals: 1), + "EUR": Unit(label: "cent", perKwh: "cent/kWh", scale: 1, decimals: 1), + "PLN": Unit(label: "gr", perKwh: "gr/kWh", scale: 1, decimals: 1), + "CHF": Unit(label: "Rp.", perKwh: "Rp./kWh", scale: 1, decimals: 1), + "CZK": Unit(label: "Kč", perKwh: "Kč/kWh", scale: 0.01, decimals: 2), + "HUF": Unit(label: "Ft", perKwh: "Ft/kWh", scale: 0.01, decimals: 1), + "RON": Unit(label: "lei", perKwh: "lei/kWh", scale: 0.01, decimals: 2), + ] + + public static func unit(_ currency: String?) -> Unit { + let code = (currency ?? "SEK").isEmpty ? "SEK" : (currency ?? "SEK").uppercased() + return units[code] ?? Unit(label: code, perKwh: "\(code)/kWh", scale: 0.01, decimals: 3) + } + + public static func display(_ minor: Double, _ currency: String?) -> Double { + minor * unit(currency).scale + } + + /// "17.4", in the chart's unit and precision. + public static func text(_ minor: Double?, _ currency: String?) -> String? { + guard let minor, minor.isFinite else { return nil } + return PowerFormat.fixed(display(minor, currency), unit(currency).decimals) + } + + /// Whether a price window misses hours rather than ending early: a head + /// that starts after the window asked for, or a hole in the middle. + public static func hasHole(_ slots: [PriceSlot], fromMs: Double) -> Bool { + guard let first = slots.first else { return false } + if first.startMs > fromMs { return true } + for i in slots.indices.dropFirst() where slots[i - 1].startMs + slots[i - 1].durationMs < slots[i].startMs { + return true + } + return false + } +} + +/// The box's compact savings card, from GET /api/savings/daily. +public enum Savings { + public struct Day: Equatable, Sendable { + public let day: String + public let savedOre: Double + public let resolution: String + } + + public struct Period: Equatable, Sendable { + public let savedMinor: Double + public let pricedDays: Int + public let totalDays: Int + public var available: Bool { pricedDays > 0 } + public var complete: Bool { totalDays > 0 && pricedDays == totalDays } + } + + public struct Periods: Equatable, Sendable { + public let today: Period + public let week: Period + public let month: Period + } + + public static func day(_ row: JSON) -> Day? { + guard let d = row["day"]?.string, d.count == 10, d.dropFirst(4).first == "-" else { return nil } + return Day(day: d, savedOre: row["saved_ore"]?.number ?? 0, resolution: row["resolution"]?.string ?? "slot") + } + + static func summarize(_ rows: [Day]) -> Period { + let priced = rows.filter { $0.resolution != "no_prices" } + return Period(savedMinor: priced.reduce(0) { $0 + $1.savedOre }, pricedDays: priced.count, totalDays: rows.count) + } + + public static func periods(_ days: [Day]) -> Periods { + let rows = days.sorted { $0.day < $1.day } + let month = rows.last.map { String($0.day.prefix(7)) } ?? "" + return Periods( + today: summarize(Array(rows.suffix(1))), + week: summarize(Array(rows.suffix(7))), + month: summarize(month.isEmpty ? [] : rows.filter { $0.day.hasPrefix(month + "-") }) + ) + } + + /// Signed major units: "+12.4", "−3.10". The currency sits in the heading. + public static func compact(_ minor: Double) -> String { + let major = minor / 100 + let a = abs(major) + let digits = a >= 100 ? 0 : a >= 10 ? 1 : 2 + return (major >= 0 ? "+" : "−") + PowerFormat.fixed(a, digits) + } +} + +/// Sentences for a command's fate. One table for every op: the codes are +/// about the door, not about what was asked. +public enum CommandText { + public static func help(_ r: CmdResult) -> String { + switch r.errorCode { + case "E_PRECONDITION": return "Your home changed while that was sending. Have another go." + case "E_CONFLICT": return "Something else changed the setting first. Try again." + case "E_SCOPE_DENIED": return "You don't have permission to change how this home runs." + case "E_CMD_EXPIRED": return "That took too long to reach your box. Try again." + case "E_BOOTING": return "Your box is still starting. Give it a minute." + case "E_UNAVAILABLE": + if r.errorArgs["op"]?.string == Contract.opLoadpointSurplusOnlySet { return "Solar rule not saved. Your previous choice is unchanged. Try again." } + return "Your box can't reach the charger right now. Try again shortly." + default: return "That didn't go through. Try again." + } + } + + /// The boost's own refusals before the door's. + public static func boostHelp(_ r: CmdResult) -> String { + if r.errorCode == "E_UNAVAILABLE", r.errorArgs["op"]?.string != nil { + return "Your box won't boost right now — the house battery or the site isn't ready for it." + } + if r.errorCode == "E_UNKNOWN_OP", r.errorArgs["arg"]?.string == "lease" { + return "Your box refused that reserve and time. Try other values." + } + return help(r) + } + + /// A level for a car that is not on the cable is refused by name. + public static func socHelp(_ r: CmdResult) -> String { + if r.errorCode == "E_UNAVAILABLE", r.errorArgs["reason"]?.string == "unplugged" { + return "Plug the car in first — your box has no car to set a level for." + } + return help(r) + } +} diff --git a/appleApp/FTWKit/Sources/FTWKit/Format/Power.swift b/appleApp/FTWKit/Sources/FTWKit/Format/Power.swift new file mode 100644 index 0000000..bbf4bea --- /dev/null +++ b/appleApp/FTWKit/Sources/FTWKit/Format/Power.swift @@ -0,0 +1,124 @@ +import Foundation + +/// Power in words and figures. +/// +/// FTW's convention is right for the wire and wrong for a person: nobody +/// reads "-4200 W" and thinks "the battery is covering the house". So the +/// UI never shows a raw minus sign. It shows a direction word and a +/// magnitude, and each screen supplies its own vocabulary. +public enum PowerFormat { + public enum Direction: Equatable, Sendable { + case into, out, idle + } + + /// Below this a reading is sensor noise. One threshold for the headline + /// and the cards, or they contradict each other on the same screen. + public static let noiseW: Double = 50 + + public struct Parts: Equatable, Sendable { + /// Magnitude in `unit`, never negative. + public let value: Double + public let unit: String + public let direction: Direction + /// Ready to show: "4.2", with decimals that keep digits from jumping. + public let text: String + + public var joined: String { "\(text) \(unit)" } + } + + public static func direction(_ watts: Double) -> Direction { + guard watts.isFinite, abs(watts) >= noiseW else { return .idle } + return watts > 0 ? .into : .out + } + + public static func parts(_ watts: Double) -> Parts { + let dir = direction(watts) + let a = watts.isFinite ? abs(watts) : 0 + if a < 1000 { + let w = a.rounded() + return Parts(value: w, unit: "W", direction: dir, text: String(Int(w))) + } + if a < 1_000_000 { + let kw = a / 1000 + return Parts(value: kw, unit: "kW", direction: dir, text: fixed(kw, kw < 10 ? 1 : 0)) + } + let mw = a / 1_000_000 + return Parts(value: mw, unit: "MW", direction: dir, text: fixed(mw, mw < 10 ? 2 : 1)) + } + + /// "4.2 kW", magnitude only. + public static func text(_ watts: Double) -> String { parts(watts).joined } + + /// A chart rung: round by construction, so no forced decimal. + public static func scale(_ watts: Double) -> String { + guard watts.isFinite else { return "" } + let a = abs(watts) + if a < 1000 { return "\(Int(a.rounded())) W" } + if a < 1_000_000 { return "\(trim(a / 1000)) kW" } + return "\(trim(a / 1_000_000)) MW" + } + + /// Permille on the wire, whole percent on screen. + public static func soc(_ permille: Double) -> String { + guard permille.isFinite else { return "—" } + return String(Int((permille / 10).rounded())) + } + + /// How old a reading is, coarse on purpose: a number ticking up every + /// second draws the eye to the staleness rather than the reading. + public static func age(_ ms: Double?) -> String { + guard let ms, ms.isFinite, ms >= 0 else { return "unknown" } + let s = Int(ms / 1000) + if s < 5 { return "just now" } + if s < 60 { return "\(s)s ago" } + let m = s / 60 + if m < 60 { return "\(m) min ago" } + let h = m / 60 + if h < 24 { return "\(h) h ago" } + return "\(h / 24) d ago" + } + + /// JavaScript's toFixed, which rounds half away from zero on the + /// decimal digits it keeps. + public static func fixed(_ v: Double, _ digits: Int) -> String { + let scale = pow(10, Double(digits)) + let r = (v * scale).rounded(.toNearestOrAwayFromZero) / scale + return String(format: "%.\(digits)f", r) + } + + static func trim(_ v: Double) -> String { + let r = (v * 10).rounded() / 10 + return r.rounded() == r ? String(Int(r)) : String(r) + } +} + +/// Energy in words. Watt-hours cross the wire and kilowatt-hours go on +/// screen, converted here and nowhere else, so the bars and the total over +/// them always add up. +public enum EnergyFormat { + /// Integer watt-hours from whatever the box sent. Every daily figure is + /// a magnitude, so a negative is a counter fault and reads as zero. + public static func wholeWh(_ value: Double?) -> Double { + guard let v = value, v.isFinite, v > 0 else { return 0 } + return v.rounded() + } + + /// "12.3" and "kWh": one decimal below ten, none above, as the box's page. + public static func parts(_ wh: Double) -> (text: String, unit: String) { + let kwh = wh / 1000 + return (PowerFormat.fixed(kwh, kwh < 10 ? 1 : 0), "kWh") + } + + public static func label(_ wh: Double) -> String { + let p = parts(wh) + return "\(p.text) \(p.unit)" + } + + /// Compact kWh for a bubble line, the dashboard's fmtKwhShort. + public static func short(_ kwh: Double) -> String { + let v = abs(kwh) + if v >= 100 { return PowerFormat.fixed(kwh, 0) } + if v >= 10 { return PowerFormat.fixed(kwh, 1) } + return PowerFormat.fixed(kwh, 2) + } +} diff --git a/appleApp/FTWKit/Sources/FTWKit/Format/PriceStrip.swift b/appleApp/FTWKit/Sources/FTWKit/Format/PriceStrip.swift new file mode 100644 index 0000000..e04c0ac --- /dev/null +++ b/appleApp/FTWKit/Sources/FTWKit/Format/PriceStrip.swift @@ -0,0 +1,83 @@ +import Foundation + +/// The price card's reading of a window: the price now, the cheapest two +/// hours ahead, and a tone for every slot still to come. The box's +/// price-summary.js, price-strip.js and price-math.js, on the consumer +/// total the box already computed. +public enum PriceStrip { + public enum Tone: Sendable { case dear, cheap, flat, negative } + + public struct Bar: Identifiable, Equatable, Sendable { + public let startMs: Double + public let endMs: Double + /// Minor units per kWh, the consumer total. + public let minor: Double + public let tone: Tone + public let current: Bool + public var id: Double { startMs } + } + + public struct Block: Equatable, Sendable { + public let meanMinor: Double + public let startMs: Double + public let endMs: Double + } + + public struct Summary: Equatable, Sendable { + public let current: PriceSlot? + /// Every slot not yet over, the current one included. + public let bars: [Bar] + public let meanMinor: Double? + /// The cheapest two whole hours in a row, not the cheapest single + /// slot: a dishwasher or a car top-up runs in blocks, not troughs. + public let cheapest: Block? + } + + public static func summary(_ prices: Prices, nowMs: Double) -> Summary { + let slots = prices.slots.filter { $0.totalMinor.isFinite }.sorted { $0.startMs < $1.startMs } + let current = slots.first { nowMs >= $0.startMs && nowMs < $0.startMs + $0.durationMs } + let upcoming = slots.filter { $0.startMs + $0.durationMs > nowMs } + guard upcoming.count >= 2 else { + return Summary(current: current, bars: [], meanMinor: nil, cheapest: bestBlock(upcoming, hours: 2, cheapest: true)) + } + let values = upcoming.map(\.totalMinor) + let mean = values.reduce(0, +) / Double(values.count) + let hi = values.max() ?? 0, lo = values.min() ?? 0 + let flat = max(hi - lo, 1) * 0.05 + let bars = upcoming.map { s -> Bar in + let tone: Tone = s.totalMinor < 0 ? .negative : abs(s.totalMinor - mean) < flat ? .flat : s.totalMinor > mean ? .dear : .cheap + return Bar(startMs: s.startMs, endMs: s.startMs + s.durationMs, minor: s.totalMinor, tone: tone, current: s.startMs == current?.startMs) + } + return Summary(current: current, bars: bars, meanMinor: mean, cheapest: bestBlock(upcoming, hours: 2, cheapest: true)) + } + + /// The contiguous run of `hours` with the lowest (or highest) mean. + public static func bestBlock(_ slots: [PriceSlot], hours: Double, cheapest: Bool) -> Block? { + let need = hours * 3_600_000 + var best: Block? + for i in slots.indices { + var span = 0.0, sum = 0.0, count = 0.0 + var j = i + while j < slots.count, span < need { + if j > i, slots[j].startMs != slots[j - 1].startMs + slots[j - 1].durationMs { break } + span += slots[j].durationMs + sum += slots[j].totalMinor + count += 1 + j += 1 + } + guard span >= need, count > 0 else { continue } + let mean = sum / count + if best == nil || (cheapest ? mean < best!.meanMinor : mean > best!.meanMinor) { + best = Block(meanMinor: mean, startMs: slots[i].startMs, endMs: slots[i].startMs + span) + } + } + return best + } + + /// The card's figure: no decimals from a hundred up, else the unit's. + public static func text(_ minor: Double?, _ currency: String?) -> String { + guard let minor, minor.isFinite else { return "—" } + let shown = PriceUnits.display(minor, currency) + return PowerFormat.fixed(shown, abs(shown) >= 100 ? 0 : PriceUnits.unit(currency).decimals) + } +} diff --git a/appleApp/FTWKit/Sources/FTWKit/Identity/BoxCode.swift b/appleApp/FTWKit/Sources/FTWKit/Identity/BoxCode.swift new file mode 100644 index 0000000..63b28b1 --- /dev/null +++ b/appleApp/FTWKit/Sources/FTWKit/Identity/BoxCode.swift @@ -0,0 +1,70 @@ +import Foundation + +/// The box code: eight characters somebody reads out loud from the box's +/// screen. They decode to the same five bytes a scanned code's payload +/// carries and are spent in the same place, handshake message 1. +/// +/// Crockford base32 without I, L, O and U. Every fold happens here, before an +/// attempt is spent: the box burns a code after five wrong tries, so a typo +/// this file could have normalised must never cost the household its code. +/// Mirrors DecodeSpokenCode in the box's appenroll/boxcode.go. +public enum BoxCode { + public static let bytes = 5 + public static let chars = 8 + + static let alphabet = Array("0123456789ABCDEFGHJKMNPQRSTVWXYZ") + static let ignored: Set = [" ", "-", "\t"] + + public struct BoxCodeError: Error, Equatable, HelpfulError { + public let message: String + public let help: String + } + + static let notACode = "That is not a code from your box. It is eight characters, like 04HM-ASW9." + + private static func fold(_ c: Character) -> Character { + switch c { + case "I", "L": return "1" + case "O": return "0" + default: return c + } + } + + /// Fold what someone typed onto the alphabet, dropping what does not + /// belong. For the input field, on every keystroke. + public static func fold(_ typed: String) -> String { + var out = "" + for raw in typed.uppercased() { + if ignored.contains(raw) { continue } + let c = fold(raw) + if alphabet.contains(c) { out.append(c) } + } + return String(out.prefix(chars)) + } + + /// XXXX-XXXX. The hyphen is for the reader. + public static func group(_ folded: String) -> String { + folded.count > 4 ? "\(folded.prefix(4))-\(folded.dropFirst(4))" : folded + } + + /// The five bytes the box drew. A character outside the alphabet means + /// the person is reading the wrong line, so it is refused rather than + /// dropped. Nothing here reaches the box. + public static func decode(_ typed: String) throws -> Bytes { + var chars = [Character]() + for raw in typed.uppercased() { + if ignored.contains(raw) { continue } + let c = fold(raw) + guard alphabet.contains(c) else { + throw BoxCodeError(message: "\(raw) is not in the alphabet", help: notACode) + } + chars.append(c) + } + guard chars.count == Self.chars else { + throw BoxCodeError(message: "\(chars.count) characters, expected \(Self.chars)", help: notACode) + } + var n: UInt64 = 0 + for c in chars { n = n << 5 | UInt64(alphabet.firstIndex(of: c)!) } + return (0..> UInt64(8 * (bytes - 1 - $0))) } + } +} diff --git a/appleApp/FTWKit/Sources/FTWKit/Identity/Enrollment.swift b/appleApp/FTWKit/Sources/FTWKit/Identity/Enrollment.swift new file mode 100644 index 0000000..5d03d9a --- /dev/null +++ b/appleApp/FTWKit/Sources/FTWKit/Identity/Enrollment.swift @@ -0,0 +1,150 @@ +import Foundation + +/// The pairing payload. +/// +/// https://app.ftw.energy/p#v2.... +/// +/// Everything after `#` is a fragment and never sent in a request, so the +/// trust anchor reaches the phone optically and reaches no server on the way. +public struct Enrollment: Equatable, Sendable { + /// The box's Noise static public key. The trust anchor. + public var boxStaticPublic: Bytes + /// Single use. The box refuses it a second time. Never log this. + public var pairingCode: Bytes + /// Where the box believes it can be reached on the LAN. May be empty. + public var lanHint: String + /// The long-lived secret the rotating relay handle is derived from. + public var rendezvousSecret: Bytes + + public static let version = "v2" + public static let host = Origin.appHost + public static let path = "/p" + public static let boxKeyBytes = 32 + public static let pairingCodeBytes = 16 + public static let rendezvousSecretBytes = 32 + public static let maxLanHintChars = 64 + + public init(boxStaticPublic: Bytes, pairingCode: Bytes, lanHint: String, rendezvousSecret: Bytes) { + self.boxStaticPublic = boxStaticPublic + self.pairingCode = pairingCode + self.lanHint = lanHint + self.rendezvousSecret = rendezvousSecret + } +} + +/// What the user does next. Never which check failed. +public struct EnrollmentError: Error, Equatable, HelpfulError { + public let code: String + public let message: String + public let help: String + + static let scanAgain = "That code did not read cleanly. Hold the phone steady and scan it again." + static let wrongCode = "That is not an FTW pairing code. Open your box's local dashboard, then Settings → FTW app → Show pairing code, and scan the QR shown there." + static let appTooOld = "This box needs a newer version of the app. Update the app, then scan again." + static let boxTooOld = "This box needs a software update before it can pair. Update the box, then scan again." +} + +/// An error that carries a sentence the person holding the phone can act on. +public protocol HelpfulError: Error { + var help: String { get } +} + +extension Enrollment { + /// Parse whatever a camera, a paste or a link handed over: a full URL or + /// just the fragment. + public static func parse(scanned raw: String) throws -> Enrollment { + let text = raw.trimmingCharacters(in: .whitespacesAndNewlines) + if text.isEmpty { + throw EnrollmentError(code: "E_QR_NOT_FTW", message: "nothing scanned", help: "That code did not scan. Try again.") + } + return text.hasPrefix("#") ? try parse(fragment: text) : try parse(url: text) + } + + public static func parse(url raw: String) throws -> Enrollment { + guard let components = URLComponents(string: raw), let scheme = components.scheme else { + throw EnrollmentError(code: "E_QR_NOT_FTW", message: "not a URL", help: EnrollmentError.wrongCode) + } + guard scheme.lowercased() == "https" else { + throw EnrollmentError(code: "E_QR_NOT_FTW", message: "scheme \(scheme) is not https", help: EnrollmentError.wrongCode) + } + guard components.host?.lowercased() == host, components.port == nil, components.user == nil else { + throw EnrollmentError(code: "E_QR_NOT_FTW", message: "host is not FTW", help: EnrollmentError.wrongCode) + } + guard components.path == path else { + throw EnrollmentError(code: "E_QR_NOT_FTW", message: "path \(components.path) is not \(path)", help: EnrollmentError.wrongCode) + } + // The fragment as written, not percent-decoded: every segment is + // base64url and a decoded one would be a different string. + guard let hashIndex = raw.firstIndex(of: "#") else { + throw EnrollmentError(code: "E_QR_NOT_FTW", message: "no fragment", help: EnrollmentError.wrongCode) + } + return try parse(fragment: String(raw[hashIndex...])) + } + + public static func parse(fragment raw: String) throws -> Enrollment { + let body = raw.hasPrefix("#") ? String(raw.dropFirst()) : raw + if body.isEmpty { + throw EnrollmentError(code: "E_QR_NOT_FTW", message: "empty fragment", help: EnrollmentError.wrongCode) + } + let parts = body.split(separator: ".", omittingEmptySubsequences: false).map(String.init) + let v = parts[0] + + // Version before shape, and which side is behind decides the + // sentence. Telling someone to update the wrong thing is worse than + // saying nothing. + if v != version { + if v.count > 1, v.hasPrefix("v"), let n = Int(v.dropFirst()), v.dropFirst().allSatisfy(\.isNumber) { + let ours = Int(version.dropFirst())! + throw EnrollmentError(code: "E_QR_VERSION", message: "payload version \(v)", help: n < ours ? EnrollmentError.boxTooOld : EnrollmentError.appTooOld) + } + throw EnrollmentError(code: "E_QR_NOT_FTW", message: "fragment does not start with a version", help: EnrollmentError.wrongCode) + } + guard parts.count == 5 else { + throw EnrollmentError(code: "E_QR_SHAPE", message: "\(parts.count) segments, expected 5", help: EnrollmentError.scanAgain) + } + + let box = try segment(parts[1]) + let code = try segment(parts[2]) + let hintBytes = try segment(parts[3]) + let secret = try segment(parts[4]) + + guard box.count == boxKeyBytes else { + throw EnrollmentError(code: "E_QR_KEY", message: "box key is \(box.count) bytes", help: EnrollmentError.scanAgain) + } + guard code.count == pairingCodeBytes else { + throw EnrollmentError(code: "E_QR_CODE", message: "pairing code is \(code.count) bytes", help: EnrollmentError.scanAgain) + } + // The hint becomes a connection target later, so it is checked here. + guard let hint = String(bytes: hintBytes, encoding: .utf8), + hint.count <= maxLanHintChars, + hint.unicodeScalars.allSatisfy({ $0.value >= 0x21 && $0.value <= 0x7e }) else { + throw EnrollmentError(code: "E_QR_HINT", message: "lan hint is not a plain address", help: EnrollmentError.scanAgain) + } + // Downstream this is HKDF input keying material, and a short secret + // there is a handle an attacker can enumerate. + guard secret.count == rendezvousSecretBytes else { + throw EnrollmentError(code: "E_QR_SECRET", message: "rendezvous secret is \(secret.count) bytes", help: EnrollmentError.scanAgain) + } + return Enrollment(boxStaticPublic: box, pairingCode: code, lanHint: hint, rendezvousSecret: secret) + } + + /// The inverse, for tests and the loopback box. + public func url() -> String { + let fragment = [ + Enrollment.version, + Base64url.encode(boxStaticPublic), + Base64url.encode(pairingCode), + Base64url.encode(Array(lanHint.utf8)), + Base64url.encode(rendezvousSecret), + ].joined(separator: ".") + return "https://\(Enrollment.host)\(Enrollment.path)#\(fragment)" + } + + private static func segment(_ s: String) throws -> Bytes { + do { + return try Base64url.decode(s) + } catch { + throw EnrollmentError(code: "E_QR_ENCODING", message: "segment is not base64url", help: EnrollmentError.scanAgain) + } + } +} diff --git a/appleApp/FTWKit/Sources/FTWKit/Identity/Escrow.swift b/appleApp/FTWKit/Sources/FTWKit/Identity/Escrow.swift new file mode 100644 index 0000000..e4370f5 --- /dev/null +++ b/appleApp/FTWKit/Sources/FTWKit/Identity/Escrow.swift @@ -0,0 +1,296 @@ +import Foundation +#if canImport(FoundationNetworking) +import FoundationNetworking +#endif + +/// The sealed copy Sourceful holds, so a new phone is not a dead end. +/// +/// "Sourceful holds a sealed copy it cannot open, with an opaque id and +/// nothing beside it." Sealed under a key derived from PRF output; the id is +/// an HKDF sibling of that key; a write is signed, a read is not, because a +/// fresh install has a passkey and nothing else. The wire is the web app's, +/// so either app reads what the other wrote. +/// +/// Losing the service costs a QR scan. Nothing depends on it. +@MainActor +public final class Escrow { + /// Every request body is exactly this long; the service refuses others. + static let requestBytes = 1024 + + public enum SaveOutcome: Equatable, Sendable { + case saved, cleared, unsupported, unreachable, failed, conflict + } + + public enum RemoveOutcome: Equatable, Sendable { + case removed, nothingToRemove, declined, kept + } + + public struct EscrowError: Error, Equatable, HelpfulError { + public let code: String + public let message: String + public let help: String + } + + /// One POST to one path. Injected by tests. + public typealias Transport = @MainActor (_ body: Bytes) async throws -> (status: Int, body: Bytes) + + private let transport: Transport + private let vault: Vault + private let sites: SiteList + + public init(vault: Vault, sites: SiteList, transport: Transport? = nil) { + self.vault = vault + self.sites = sites + self.transport = transport ?? Escrow.urlSessionTransport(Origin.escrowURL.appendingPathComponent("e")) + } + + // MARK: Which homes + + /// The homes this household asked to be held, oldest first. + public func escrowedHomes() -> [RecoveryBlob.Home] { + sites.all() + .filter { $0.escrow && $0.rendezvousSecret != nil } + .sorted { $0.addedAtMs < $1.addedAtMs } + .map { RecoveryBlob.Home(siteId: $0.siteId, label: $0.label, boxStaticKey: $0.boxStaticKey.byteArray, rendezvousSecret: $0.rendezvousSecret!.byteArray) } + } + + public func mark(_ siteId: String, _ on: Bool) { + sites.update(siteId) { $0.escrow = on } + } + + // MARK: Writing + + /// Put the marked homes in the escrow, replacing what was there. Reports + /// rather than throws: a device with no spare copy is the device everyone + /// had yesterday. + public func save(_ wrapping: WrappingKey, pending: RecoveryBlob.Home? = nil) async -> SaveOutcome { + guard let keys = wrapping.escrow else { return .unsupported } + do { + var homes = escrowedHomes() + if let pending { + homes = homes.filter { $0.siteId != pending.siteId } + [pending] + } + if homes.isEmpty { + return try await clear(keys) ? .cleared : .conflict + } + var scalar = try vault.exportDeviceSecret(wrapping) + defer { wipe(&scalar) } + // Twice at most. The version is bound into the seal, so losing a + // race means sealing again under the new number. + for _ in 0..<2 { + let held = try await readHeld(keys.lookupID) + let version = (held?.version ?? 0) + 1 + let sealed = try RecoveryBlob.seal(key: keys.sealKey, .init(deviceScalar: scalar, homes: homes), escrowVersion: version) + if try await put(keys, version: version, blob: sealed) { return .saved } + } + return .conflict + } catch is EscrowError { + return .unreachable + } catch { + return .failed + } + } + + /// Take the copy away. Costs nothing at all when no home opted in. + public func remove(_ passkeys: PasskeyAuthenticator?) async -> RemoveOutcome { + if escrowedHomes().isEmpty { return .nothingToRemove } + let wrapping: WrappingKey + do { + wrapping = try await vault.unlockWrappingKey(passkeys) + } catch is PasskeyCancelled { + return .declined + } catch { + return .kept + } + guard let keys = wrapping.escrow else { return .kept } + do { + return try await clear(keys) ? .removed : .kept + } catch { + return .kept + } + } + + /// Empty the copy rather than delete the row: a delete would restart the + /// version at 1, and a kept old blob could then be written straight back. + private func clear(_ keys: EscrowKeys) async throws -> Bool { + for _ in 0..<2 { + guard let held = try await readHeld(keys.lookupID) else { return true } + if held.blob.isEmpty { return true } + if try await put(keys, version: held.version + 1, blob: []) { return true } + } + return false + } + + // MARK: Bringing a home back + + public struct Recovered: Identifiable, Sendable { + public let siteId: String + public let label: String + public let fingerprint: String + let deviceScalar: Bytes + let wrapping: WrappingKey + let everyHome: [RecoveryBlob.Home] + public var id: String { siteId } + } + + /// One ceremony yields the id and the key. An empty result is the + /// ordinary answer for a passkey that never saved anything; a copy that is + /// there and will not open throws, because that one means something + /// replaced it. + public func recover(_ passkeys: PasskeyAuthenticator?) async throws -> [Recovered] { + guard let passkeys, passkeys.isAvailable else { return [] } + let outcome = try await passkeys.assert(credentialIDs: []) + guard let prf = outcome.prfOutput else { + throw RecoveryBlob.BlobError(code: "E_BLOB_LOCKED", message: "no PRF output", help: "This passkey cannot unlock what was saved. Open your box's local dashboard and use Settings → FTW app → Show pairing code.") + } + let wrapping = PRF.wrappingKey(credentialID: outcome.credentialID, prfOutput: prf) + guard let keys = wrapping.escrow else { return [] } + guard let held = try await readHeld(keys.lookupID), !held.blob.isEmpty else { return [] } + let contents = try RecoveryBlob.open(key: keys.sealKey, held.blob, escrowVersion: held.version) + return contents.homes.map { + Recovered(siteId: $0.siteId, label: $0.label, fingerprint: SiteList.fingerprint($0.boxStaticKey), deviceScalar: contents.deviceScalar, wrapping: wrapping, everyHome: contents.homes) + } + } + + /// Write a recovered home down. The device key is put back rather than + /// minted, wrapped under the local key so connecting stays silent and + /// under the passkey that just answered. Every home in the copy is + /// written, because the next save rebuilds the copy from this disk. + @discardableResult + public func adopt(_ home: Recovered, nowMs: Double) throws -> String { + let local = vault.localWrappingKey() + try vault.restoreDeviceKey(local, scalar: home.deviceScalar) + try vault.addCredential(current: local, next: home.wrapping) + for each in home.everyHome { + sites.put(StoredSite( + siteId: each.siteId, + label: each.label, + boxStaticKey: Data(each.boxStaticKey), + rendezvousSecret: Data(each.rendezvousSecret), + pairingCode: nil, + lanHint: nil, + escrow: true, + addedAtMs: nowMs, + lastSeenAtMs: nowMs + )) + } + return home.siteId + } + + // MARK: The wire + + struct Held { + var version: UInt32 + var blob: Bytes + } + + private func readHeld(_ lookupID: String) async throws -> Held? { + let response = try await request([("op", .string("get")), ("id", .string(lookupID))]) + if response.status == 404 { return nil } + guard response.status == 200 else { throw refused(response.status) } + guard let object = try? JSONSerialization.jsonObject(with: Data(response.body)) as? [String: Any], + let version = (object["version"] as? NSNumber)?.uint32Value, + let text = object["blob"] as? String, + let blob = Data(base64Encoded: text)?.byteArray, + blob.count == RecoveryBlob.maxBytes || blob.isEmpty else { + throw refused(response.status) + } + return Held(version: version, blob: blob) + } + + /// The bytes a write is signed over; the service's `writeMessage`. + static func writeMessage(lookupID: String, version: UInt32, blob: Bytes) -> Bytes { + Array("ftw-escrow:v1:\(lookupID):\(version):\(Primitives.sha256(blob).hex)".utf8) + } + + /// True when it landed; false is a lost race and nothing else. + private func put(_ keys: EscrowKeys, version: UInt32, blob: Bytes) async throws -> Bool { + let signature = try keys.sign(Self.writeMessage(lookupID: keys.lookupID, version: version, blob: blob)) + let response = try await request([ + ("op", .string("put")), + ("id", .string(keys.lookupID)), + ("version", .number(Int(version))), + ("blob", .string(Data(blob).base64EncodedString())), + ("pub", .string(Base64url.encode(keys.writeKey))), + ("sig", .string(Base64url.encode(signature))), + ]) + if response.status == 200 { return true } + if response.status == 409 { return false } + throw refused(response.status) + } + + enum Field { + case string(String) + case number(Int) + } + + /// `body` as JSON with a `pad` member that makes the whole exactly + /// `bytes` long, the way the web app pads it. The id never goes in a URL: + /// a URL is what every layer writes down. + static func padded(_ fields: [(String, Field)], to bytes: Int = requestBytes) -> Bytes { + func render(_ pad: String) -> String { + var parts = fields.map { key, value -> String in + switch value { + case .string(let s): return "\(jsonString(key)):\(jsonString(s))" + case .number(let n): return "\(jsonString(key)):\(n)" + } + } + parts.append("\"pad\":\(jsonString(pad))") + return "{" + parts.joined(separator: ",") + "}" + } + let empty = render("") + return Array(render(String(repeating: "A", count: max(0, bytes - empty.utf8.count))).utf8) + } + + private func request(_ fields: [(String, Field)]) async throws -> (status: Int, body: Bytes) { + do { + return try await transport(Self.padded(fields)) + } catch { + throw EscrowError(code: "E_ESCROW_UNREACHABLE", message: "the escrow could not be reached", help: "The saved copy could not be reached. Your home works either way — try again when you are back online.") + } + } + + private func refused(_ status: Int) -> EscrowError { + EscrowError(code: "E_ESCROW_REFUSED", message: "the escrow answered \(status)", help: "The saved copy could not be read. Open your box's local dashboard, then Settings → FTW app → Show pairing code, and scan a new QR instead.") + } + + /// No cookies, no cache, nothing an origin could be recognised by. + static func urlSessionTransport(_ url: URL) -> Transport { + { body in + let config = URLSessionConfiguration.ephemeral + config.httpCookieStorage = nil + config.urlCache = nil + config.requestCachePolicy = .reloadIgnoringLocalCacheData + let session = URLSession(configuration: config) + defer { session.finishTasksAndInvalidate() } + var request = URLRequest(url: url) + request.httpMethod = "POST" + request.setValue("application/json", forHTTPHeaderField: "content-type") + request.httpBody = Data(body) + let (data, response) = try await session.data(for: request) + return ((response as? HTTPURLResponse)?.statusCode ?? 0, data.byteArray) + } + } +} + +/// A JSON string literal. Only what JSON requires is escaped, which is what +/// JavaScript's JSON.stringify does for these ASCII values. +func jsonString(_ s: String) -> String { + var out = "\"" + for scalar in s.unicodeScalars { + switch scalar { + case "\"": out += "\\\"" + case "\\": out += "\\\\" + case "\n": out += "\\n" + case "\r": out += "\\r" + case "\t": out += "\\t" + default: + if scalar.value < 0x20 { + out += String(format: "\\u%04x", scalar.value) + } else { + out.unicodeScalars.append(scalar) + } + } + } + return out + "\"" +} diff --git a/appleApp/FTWKit/Sources/FTWKit/Identity/Origin.swift b/appleApp/FTWKit/Sources/FTWKit/Identity/Origin.swift new file mode 100644 index 0000000..4a90b5e --- /dev/null +++ b/appleApp/FTWKit/Sources/FTWKit/Identity/Origin.swift @@ -0,0 +1,24 @@ +import Foundation + +/// Where FTW lives, decided once. The QR host, the passkey's relying party +/// and the web app's origin are the same string by construction. +public enum Origin { + /// The one origin, and the host in every pairing QR. + public static let appHost = "app.ftw.energy" + + /// The passkey relying party. The app subdomain, never the registrable + /// domain: PRF output is bound to the RP ID, so its scope decides who may + /// derive the keys. Changing it strands every passkey. + public static let rpID = appHost + public static let rpName = "FTW" + + /// The relay. One value, no setting. + public static let relayURL = URL(string: "wss://relay.ftw.energy")! + + /// The sealed recovery copy. Its own host, neither the relay's nor the app's. + public static let escrowURL = URL(string: "https://escrow.ftw.energy")! + + /// Source and licence, as the AGPL asks a network client to offer them. + public static let sourceURL = URL(string: "https://github.com/srcfl/ftw-app")! + public static let licenseURL = URL(string: "https://github.com/srcfl/ftw-app/blob/main/LICENSE")! +} diff --git a/appleApp/FTWKit/Sources/FTWKit/Identity/PRF.swift b/appleApp/FTWKit/Sources/FTWKit/Identity/PRF.swift new file mode 100644 index 0000000..49cb62b --- /dev/null +++ b/appleApp/FTWKit/Sources/FTWKit/Identity/PRF.swift @@ -0,0 +1,106 @@ +import Foundation + +/// The passkey as a key derivation function. +/// +/// There is no account and nothing verifies an assertion. The credential +/// exists because the PRF extension hands back a secret only after the user +/// verifies, and that secret is turned into keys here, the same way the web +/// app turns it into keys. Same RP ID, same salt, same HKDF: a passkey that +/// sealed a recovery copy in the web app opens it in this app, and the +/// reverse. +public enum PRF { + /// One fixed salt per purpose, forever. The authenticator is only ever + /// asked for the vault salt; the escrow salt is an HKDF salt. + public static let vaultSalt = Array("ftw.prf.v1.vault".utf8) + public static let escrowSalt = Array("ftw.prf.v1.escrow".utf8) + + static let wrapInfo = Array("ftw.wrap.v1".utf8) + static let escrowIDInfo = Array("ftw.escrow.id.v1".utf8) + static let escrowKeyInfo = Array("ftw.escrow.key.v1".utf8) + static let escrowWriteInfo = Array("ftw.escrow.write.v1".utf8) + + /// The AES-256-GCM key that wraps the device key, from one PRF output. + public static func wrappingKey(credentialID: String, prfOutput: Bytes) -> WrappingKey { + WrappingKey( + credentialID: credentialID, + source: .prf, + key: Primitives.hkdf(ikm: prfOutput, salt: vaultSalt, info: wrapInfo, length: 32), + escrow: EscrowKeys(prfOutput: prfOutput) + ) + } +} + +/// Where a wrapping key came from. The UI states this; it never hides it. +public enum WrappingSource: String, Codable, Sendable { + case prf + case local +} + +public struct WrappingKey: Sendable { + /// base64url credential id, or `Vault.localCredentialID`. + public let credentialID: String + public let source: WrappingSource + /// 32 bytes of AES-256-GCM key. + let key: Bytes + /// Present only where PRF is. The local key derives nothing that may + /// leave this device. + public let escrow: EscrowKeys? +} + +/// What the escrow needs, as HKDF siblings of the vault key: the id says +/// nothing about the key, and neither can be produced from the other. +public struct EscrowKeys: Sendable { + /// base64url of 32 bytes. The only name the service ever learns. + public let lookupID: String + let sealKey: Bytes + /// Ed25519 public key the service pins on first write. + public let writeKey: Bytes + private let writeSeed: Bytes + + init(prfOutput: Bytes) { + let salt = PRF.escrowSalt + lookupID = Base64url.encode(Primitives.hkdf(ikm: prfOutput, salt: salt, info: PRF.escrowIDInfo, length: 32)) + sealKey = Primitives.hkdf(ikm: prfOutput, salt: salt, info: PRF.escrowKeyInfo, length: 32) + writeSeed = Primitives.hkdf(ikm: prfOutput, salt: salt, info: PRF.escrowWriteInfo, length: 32) + // A 32-byte seed is always a valid Ed25519 key. + writeKey = (try? Primitives.ed25519PublicKey(seed: writeSeed)) ?? [] + } + + func sign(_ message: Bytes) throws -> Bytes { + try Primitives.ed25519Sign(seed: writeSeed, message: message) + } +} + +/// The platform side of a passkey ceremony, injected so this package never +/// touches AuthenticationServices. +@MainActor +public protocol PasskeyAuthenticator: AnyObject { + /// Register a new passkey and ask for PRF. `prfOutput` is nil when the + /// platform registered the passkey but gave no PRF. + func register(label: String, userHandle: Bytes, excludeCredentialIDs: [String]) async throws -> PasskeyOutcome + /// Assert with PRF. An empty list means any discoverable credential for + /// the RP, which is what a fresh install recovering its home needs. + func assert(credentialIDs: [String]) async throws -> PasskeyOutcome + /// Whether this device can run a ceremony at all. + var isAvailable: Bool { get } +} + +public struct PasskeyOutcome: Sendable { + public let credentialID: String + public let prfOutput: Bytes? + /// At registration: the platform says PRF is supported even though it + /// gave no output yet, so an assertion will produce one. + public let prfEnabled: Bool + + public init(credentialID: String, prfOutput: Bytes?, prfEnabled: Bool = false) { + self.credentialID = credentialID + self.prfOutput = prfOutput + self.prfEnabled = prfEnabled + } +} + +/// The person dismissed the sheet. A decline is an answer, not a fault, and +/// the two need different sentences. +public struct PasskeyCancelled: Error, Equatable { + public init() {} +} diff --git a/appleApp/FTWKit/Sources/FTWKit/Identity/Pairing.swift b/appleApp/FTWKit/Sources/FTWKit/Identity/Pairing.swift new file mode 100644 index 0000000..3f3f358 --- /dev/null +++ b/appleApp/FTWKit/Sources/FTWKit/Identity/Pairing.swift @@ -0,0 +1,91 @@ +import Foundation + +/// From a scanned code to a paired home, in the order that keeps it to two +/// taps and fails before the passkey prompt whenever it can: +/// +/// 1. Parse the QR. A bad code fails before anyone is asked for Face ID. +/// 2. Ask for the passkey, once. +/// 3. Unwrap or create the device key, and make the local copy. +/// 4. Store the home, pinned to the box key from the QR. +/// +/// Only then does anything touch the network, and the spare copy is written +/// in the background without delaying the house. +@MainActor +public final class Pairing { + private let vault: Vault + private let sites: SiteList + private let escrow: Escrow + private let passkeys: PasskeyAuthenticator? + private let now: () -> Double + + public init(vault: Vault, sites: SiteList, escrow: Escrow, passkeys: PasskeyAuthenticator?, now: @escaping () -> Double = { Date().timeIntervalSince1970 * 1000 }) { + self.vault = vault + self.sites = sites + self.escrow = escrow + self.passkeys = passkeys + self.now = now + } + + public struct Paired: Sendable { + public let site: StoredSite + /// The background seal of the spare copy, for tests to await. + public let sealed: Task + } + + /// Throws `EnrollmentError` with a sentence, or `PasskeyCancelled`. + public func pair(scanned: String, holdCopy: Bool = true) async throws -> Paired { + let enrollment = try Enrollment.parse(scanned: scanned) + + let wrapping = vault.isEnrolled + ? try await vault.unlockWrappingKey(passkeys) + : try await vault.enrollWrappingKey(passkeys) + _ = try vault.deviceKey(wrapping) + // The last prompt reading ever costs. PRF keeps guarding enrollment + // and privileged writes; looking at your own house is not one. + try vault.ensureLocalCopy(wrapping) + + let at = now() + let siteId = SiteList.siteID(enrollment.boxStaticPublic) + let site = StoredSite( + siteId: siteId, + label: sites.get(siteId)?.label ?? "Home", + boxStaticKey: Data(enrollment.boxStaticPublic), + rendezvousSecret: Data(enrollment.rendezvousSecret), + pairingCode: Data(enrollment.pairingCode), + lanHint: enrollment.lanHint.isEmpty ? nil : enrollment.lanHint, + escrow: sites.get(siteId)?.escrow ?? false, + addedAtMs: at, + lastSeenAtMs: at + ) + sites.put(site) + + // Hold a sealed copy from the first device, by default and in the + // background. The mark follows the write, so the Box screen never + // says a copy is held when it is not. + let escrow = self.escrow + let sealed = Task { @MainActor () -> Escrow.SaveOutcome? in + guard holdCopy, wrapping.escrow != nil else { return nil } + let outcome = await escrow.save(wrapping, pending: RecoveryBlob.Home( + siteId: site.siteId, + label: site.label, + boxStaticKey: site.boxStaticKey.byteArray, + rendezvousSecret: site.rendezvousSecret?.byteArray ?? [] + )) + escrow.mark(site.siteId, outcome == .saved) + return outcome + } + return Paired(site: site, sealed: sealed) + } + + /// Arm the next handshake with a code read off the box's screen. For a + /// phone that has been here before: a box code carries no box key and no + /// rendezvous secret, so a phone with no row for this home scans instead. + /// The decode happens before the disk is touched, so a typo costs nothing. + public func redeemBoxCode(siteId: String, typed: String) throws { + let code = try BoxCode.decode(typed) + guard sites.get(siteId) != nil else { + throw BoxCode.BoxCodeError(message: "no site \(siteId)", help: "This phone has no record of that home. Open your box's local dashboard, then Settings → FTW app → Show pairing code, and scan a new QR instead.") + } + sites.update(siteId) { $0.pairingCode = Data(code) } + } +} diff --git a/appleApp/FTWKit/Sources/FTWKit/Identity/RecoveryBlob.swift b/appleApp/FTWKit/Sources/FTWKit/Identity/RecoveryBlob.swift new file mode 100644 index 0000000..320e458 --- /dev/null +++ b/appleApp/FTWKit/Sources/FTWKit/Identity/RecoveryBlob.swift @@ -0,0 +1,143 @@ +import Foundation + +/// The sealed copy of a household: the bytes, and nothing about where they +/// are kept. The same format the web app writes, so a copy either one +/// sealed opens in the other. +/// +/// sealed: [version 1][nonce 12][ciphertext + tag] +/// plain: [device scalar 32][home count 1] +/// per home: [id len 1][id][name len 1][name][box key 32][rendezvous secret 32] +/// then zeros out to the fixed plaintext length +/// AAD: [version 1][escrow version 4, big-endian] +/// +/// Padded to one length so whoever holds the ciphertext cannot count homes. +/// The escrow version is bound in as additional data, so a service that pairs +/// an old blob with a new number gets a refusal here rather than a household +/// getting back a home it had removed. +public enum RecoveryBlob { + public static let maxBytes = 512 + public static let version: UInt8 = 2 + public static let escrowVersionNone: UInt32 = 0 + static let nonceBytes = 12 + static let headerBytes = 1 + nonceBytes + static let tagBytes = 16 + public static let plainBytes = maxBytes - headerBytes - tagBytes + /// The name is for telling two homes apart, not a record. + public static let maxNameChars = 32 + + public struct Home: Equatable, Sendable { + public var siteId: String + public var label: String + public var boxStaticKey: Bytes + public var rendezvousSecret: Bytes + + public init(siteId: String, label: String, boxStaticKey: Bytes, rendezvousSecret: Bytes) { + self.siteId = siteId + self.label = label + self.boxStaticKey = boxStaticKey + self.rendezvousSecret = rendezvousSecret + } + } + + public struct Contents: Equatable, Sendable { + public var deviceScalar: Bytes + public var homes: [Home] + } + + public struct BlobError: Error, Equatable, HelpfulError { + public let code: String + public let message: String + public let help: String + } + + static func format(_ message: String) -> BlobError { + BlobError(code: "E_BLOB_FORMAT", message: message, help: "The saved copy of this home could not be read. Open your box's local dashboard, then Settings → FTW app → Show pairing code, and scan a new QR instead.") + } + + public static func encode(_ contents: Contents) throws -> Bytes { + guard contents.deviceScalar.count == 32 else { throw format("device key is not 32 bytes") } + guard contents.homes.count <= 0xff else { throw format("more homes than a byte counts") } + var out = Bytes() + out.reserveCapacity(plainBytes) + out += contents.deviceScalar + out.append(UInt8(contents.homes.count)) + for home in contents.homes { + guard home.boxStaticKey.count == Enrollment.boxKeyBytes else { throw format("box key is not 32 bytes") } + guard home.rendezvousSecret.count == Enrollment.rendezvousSecretBytes else { throw format("rendezvous secret is not 32 bytes") } + let id = Array(home.siteId.utf8) + // Whole characters: a cut between the halves of an emoji would + // keep one of them. + let name = Array(String(String.UnicodeScalarView(home.label.unicodeScalars.prefix(maxNameChars))).utf8) + guard id.count <= 0xff, name.count <= 0xff else { throw format("a name is longer than a byte") } + out.append(UInt8(id.count)) + out += id + out.append(UInt8(name.count)) + out += name + out += home.boxStaticKey + out += home.rendezvousSecret + } + guard out.count <= plainBytes else { + throw BlobError(code: "E_BLOB_TOO_BIG", message: "payload is \(out.count) bytes", help: "There are more homes on this phone than a saved copy can carry. They all still work here.") + } + out += Bytes(repeating: 0, count: plainBytes - out.count) + return out + } + + /// Strict: anything short, long or unaccounted for is refused rather than + /// read half-way. + public static func decode(_ plain: Bytes) throws -> Contents { + guard plain.count == plainBytes else { throw format("payload is \(plain.count) bytes") } + let scalar = Array(plain[0..<32]) + var at = 32 + let count = Int(plain[at]) + at += 1 + var homes = [Home]() + + func slice() throws -> Bytes { + guard at < plain.count else { throw format("payload ends in the middle of a home") } + let n = Int(plain[at]) + at += 1 + guard at + n <= plain.count else { throw format("payload ends in the middle of a name") } + defer { at += n } + return Array(plain[at.. Bytes { + [version, UInt8(escrowVersion >> 24), UInt8(escrowVersion >> 16 & 0xff), UInt8(escrowVersion >> 8 & 0xff), UInt8(escrowVersion & 0xff)] + } + + public static func seal(key: Bytes, _ contents: Contents, escrowVersion: UInt32) throws -> Bytes { + var plain = try encode(contents) + defer { wipe(&plain) } + let nonce = randomBytes(nonceBytes) + let ct = try Primitives.aesGCMSeal(key: key, nonce: nonce, ad: aad(escrowVersion), plaintext: plain) + return [version] + nonce + ct + } + + public static func open(key: Bytes, _ sealed: Bytes, escrowVersion: UInt32) throws -> Contents { + guard sealed.count > headerBytes else { throw format("blob is too short to be sealed") } + guard sealed[0] == version else { throw format("blob version \(sealed[0])") } + var plain: Bytes + do { + plain = try Primitives.aesGCMOpen(key: key, nonce: Array(sealed[1.. Primitives.KeyPair { + guard let record else { return try create(wrapping) } + guard let copy = record.copies.first(where: { $0.credentialId == wrapping.credentialID }) else { + throw noCopy(wrapping.credentialID) + } + var pkcs8 = try unseal(wrapping.key, copy) + defer { wipe(&pkcs8) } + return try Primitives.keyPair(fromSecret: Array(pkcs8.suffix(32))) + } + + /// The raw scalar, for the one caller allowed to copy it off this device: + /// the sealed recovery copy. The caller wipes what it gets. + public func exportDeviceSecret(_ wrapping: WrappingKey) throws -> Bytes { + guard let record else { throw emptyVault() } + guard let copy = record.copies.first(where: { $0.credentialId == wrapping.credentialID }) else { + throw noCopy(wrapping.credentialID) + } + var pkcs8 = try unseal(wrapping.key, copy) + defer { wipe(&pkcs8) } + return Array(pkcs8.suffix(32)) + } + + /// Put back a device key from a sealed copy: the Noise static the box + /// already trusts, which is why no pairing code is needed. Refuses to sit + /// on top of a different identity. + @discardableResult + public func restoreDeviceKey(_ wrapping: WrappingKey, scalar: Bytes) throws -> Primitives.KeyPair { + let pair = try Primitives.keyPair(fromSecret: scalar) + let existing = record + if let existing, existing.publicKey.byteArray != pair.publicKey { + throw VaultError(code: "E_VAULT_OTHER_KEY", message: "a different device key is already stored", help: "This phone is already set up for a home. Sign out of it first.") + } + var pkcs8 = Self.pkcs8Prefix + scalar + defer { wipe(&pkcs8) } + var copies = (existing?.copies ?? []).filter { $0.credentialId != wrapping.credentialID } + copies.append(try seal(wrapping, pkcs8)) + write(Record(publicKey: Data(pair.publicKey), copies: copies)) + return pair + } + + /// Wrap the device key under another credential. `current` must already + /// open it: prove you can unlock before you widen access. + public func addCredential(current: WrappingKey, next: WrappingKey) throws { + guard let record else { throw emptyVault() } + guard let copy = record.copies.first(where: { $0.credentialId == current.credentialID }) else { + throw noCopy(current.credentialID) + } + var pkcs8 = try unseal(current.key, copy) + defer { wipe(&pkcs8) } + var copies = record.copies.filter { $0.credentialId != next.credentialID } + copies.append(try seal(next, pkcs8)) + write(Record(publicKey: record.publicKey, copies: copies)) + } + + /// Refuses the last copy, which would strand the device key for good. + public func removeCredential(_ credentialID: String) throws { + guard let record else { throw emptyVault() } + let copies = record.copies.filter { $0.credentialId != credentialID } + if copies.count == record.copies.count { return } + if copies.isEmpty { + throw VaultError(code: "E_VAULT_LAST_COPY", message: "refusing to remove the only wrapped copy", help: "That is the only passkey that can unlock this device. Add another one first.") + } + write(Record(publicKey: record.publicKey, copies: copies)) + } + + /// Forget this device's identity. Callers leaving a home clear the sites + /// and the cached readings as well; see `AppModel.leave`. + public func reset() { + store.remove(Self.vaultKey) + store.remove(Self.localWrapKey) + store.remove(Self.userHandleKey) + } + + // MARK: Wrapping keys + + /// The local key: random, in the Keychain, no ceremony in front of it. + public func localWrappingKey() -> WrappingKey { + if let existing = store.get(Self.localWrapKey), existing.count == 32 { + return WrappingKey(credentialID: Self.localCredentialID, source: .local, key: existing, escrow: nil) + } + let key = randomBytes(32) + store.put(Self.localWrapKey, key) + return WrappingKey(credentialID: Self.localCredentialID, source: .local, key: key, escrow: nil) + } + + /// The key for connecting, without a prompt, or nil when only passkey + /// copies exist. Reading your own house is not a privilege. + public func silentWrappingKey() throws -> WrappingKey? { + guard let record else { throw emptyVault() } + return record.copies.contains { $0.credentialId == Self.localCredentialID } ? localWrappingKey() : nil + } + + /// Add the local copy, so the next start never prompts. + public func ensureLocalCopy(_ current: WrappingKey) throws { + guard let record else { throw emptyVault() } + if record.copies.contains(where: { $0.credentialId == Self.localCredentialID }) { return } + try addCredential(current: current, next: localWrappingKey()) + } + + /// A key for a device enrolling now: one prompt, no questions. Where PRF + /// is missing this still returns a key, the local one, and `source` says + /// so. A decline is rethrown; any other platform failure falls back. + public func enrollWrappingKey(_ passkeys: PasskeyAuthenticator?, label: String = Origin.rpName) async throws -> WrappingKey { + guard let passkeys, passkeys.isAvailable else { return localWrappingKey() } + do { + let outcome = try await passkeys.register(label: label, userHandle: userHandle(), excludeCredentialIDs: credentialIDs) + if let prf = outcome.prfOutput { + return PRF.wrappingKey(credentialID: outcome.credentialID, prfOutput: prf) + } + // Some platforms register the passkey and only evaluate PRF on an + // assertion. One more prompt now, while the person is looking, + // beats a vault nothing can open later. + guard outcome.prfEnabled else { return localWrappingKey() } + let asserted = try await passkeys.assert(credentialIDs: [outcome.credentialID]) + if let prf = asserted.prfOutput { + return PRF.wrappingKey(credentialID: asserted.credentialID, prfOutput: prf) + } + return localWrappingKey() + } catch is PasskeyCancelled { + throw PasskeyCancelled() + } catch { + return localWrappingKey() + } + } + + /// The key for a device already enrolled: one prompt, or the local copy + /// when no passkey can answer. + public func unlockWrappingKey(_ passkeys: PasskeyAuthenticator?) async throws -> WrappingKey { + guard let record else { throw emptyVault() } + let ids = passkeyCredentialIDs + if !ids.isEmpty, let passkeys, passkeys.isAvailable { + do { + let outcome = try await passkeys.assert(credentialIDs: ids) + if let prf = outcome.prfOutput { + return PRF.wrappingKey(credentialID: outcome.credentialID, prfOutput: prf) + } + } catch is PasskeyCancelled { + // A decline stays a decline. "Set this device up again" would + // tell someone who tapped cancel to throw away a working phone. + throw PasskeyCancelled() + } catch {} + } + if record.copies.contains(where: { $0.credentialId == Self.localCredentialID }) { + return localWrappingKey() + } + throw locked() + } + + // MARK: Private + + private var record: Record? { + store.getJSON(Record.self, Self.vaultKey) + } + + private func write(_ record: Record) { + store.putJSON(Self.vaultKey, record) + } + + func userHandle() -> Bytes { + if let existing = store.get(Self.userHandleKey) { return existing } + // Stable per install, so a second passkey replaces the first in the + // platform's list instead of stacking up identical entries. + let handle = randomBytes(16) + store.put(Self.userHandleKey, handle) + return handle + } + + private func create(_ wrapping: WrappingKey) throws -> Primitives.KeyPair { + let pair = Primitives.generateKeyPair() + var pkcs8 = Self.pkcs8Prefix + pair.secretKey + defer { wipe(&pkcs8) } + write(Record(publicKey: Data(pair.publicKey), copies: [try seal(wrapping, pkcs8)])) + return pair + } + + private func seal(_ wrapping: WrappingKey, _ plain: Bytes) throws -> Copy { + let iv = randomBytes(12) + let ct = try Primitives.aesGCMSeal(key: wrapping.key, nonce: iv, plaintext: plain) + return Copy(credentialId: wrapping.credentialID, source: wrapping.source, iv: Data(iv), ct: Data(ct)) + } + + private func unseal(_ key: Bytes, _ copy: Copy) throws -> Bytes { + do { + return try Primitives.aesGCMOpen(key: key, nonce: copy.iv.byteArray, ciphertext: copy.ct.byteArray) + } catch { + throw locked() + } + } + + private func locked() -> VaultError { + VaultError(code: "E_VAULT_LOCKED", message: "no wrapping key opens the stored copy", help: "This device can no longer unlock its key. Open your box's local dashboard, then Settings → FTW app → Show pairing code, and scan a new QR.") + } + + private func noCopy(_ credentialID: String) -> VaultError { + VaultError(code: "E_VAULT_NO_COPY", message: "no wrapped copy for credential \(credentialID)", help: "This passkey has not been given access yet. Unlock with the one you set up first.") + } + + private func emptyVault() -> VaultError { + VaultError(code: "E_VAULT_EMPTY", message: "no device key has been created", help: "Open your box's local dashboard, then Settings → FTW app → Show pairing code, and scan the QR.") + } +} + +/// Proving, right now, that the person holding the phone is its owner. +/// +/// The box cannot verify a ceremony happened; `stepUp: true` is this app's +/// word. What it stops is a phone left unlocked on a table being used to +/// reconfigure a house, because this app will not say `true` without the +/// ceremony below running. It never falls back to the local key. +public enum StepUpOutcome: Equatable, Sendable { + case done + case declined + case unavailable + + public var help: String? { + switch self { + case .done: return nil + case .declined: return "That needs your face or fingerprint. Try again when you are ready." + case .unavailable: return "This phone cannot confirm it is yours. Changes have to be made on your box." + } + } +} + +extension Vault { + public func stepUp(_ passkeys: PasskeyAuthenticator?) async -> StepUpOutcome { + guard let passkeys, passkeys.isAvailable else { return .unavailable } + let ids = passkeyCredentialIDs + if ids.isEmpty { return .unavailable } + do { + // The ceremony is what is asked for, not its PRF output: reaching + // the next line without throwing is what says it ran. + _ = try await passkeys.assert(credentialIDs: ids) + return .done + } catch is PasskeyCancelled { + return .declined + } catch { + return .unavailable + } + } +} diff --git a/appleApp/FTWKit/Sources/FTWKit/Protocol/CBOR.swift b/appleApp/FTWKit/Sources/FTWKit/Protocol/CBOR.swift new file mode 100644 index 0000000..1580879 --- /dev/null +++ b/appleApp/FTWKit/Sources/FTWKit/Protocol/CBOR.swift @@ -0,0 +1,416 @@ +import Foundation + +/// A CBOR value, as much of RFC 8949 as the box and the web app use. +/// +/// Maps keep their order. The web app's encoder writes keys in insertion +/// order and the box accepts any order, so keeping order is what lets this +/// encoder produce the same bytes as the web app for the same message, and +/// the shared interop vectors prove it. +public indirect enum CBOR: Equatable, Sendable { + case unsigned(UInt64) + /// The value is `-1 - n`. + case negative(UInt64) + case bytes(Bytes) + case text(String) + case array([CBOR]) + case map([Entry]) + case tagged(UInt64, CBOR) + case bool(Bool) + case null + case undefined + case double(Double) + + public struct Entry: Equatable, Sendable { + public var key: CBOR + public var value: CBOR + public init(_ key: CBOR, _ value: CBOR) { + self.key = key + self.value = value + } + } + + public struct DecodeError: Error, Equatable { + public let message: String + } +} + +// MARK: - Building + +extension CBOR { + public static func int(_ v: Int64) -> CBOR { + v >= 0 ? .unsigned(UInt64(v)) : .negative(UInt64(-1 - v)) + } + + public static func int(_ v: Int) -> CBOR { .int(Int64(v)) } + + public static let emptyMap = CBOR.map([Entry]()) + + /// A time in whole milliseconds. The box decodes every time into an + /// int64 and refuses a fraction, where JavaScript's clock never has one. + public static func ms(_ v: Double) -> CBOR { .int(Int64(v.rounded())) } + + /// A number as JavaScript would put it on the wire: an integer when it is + /// one, a double otherwise. The web app sends `hz: 1` and `hz: 0.2`, and + /// the box reads either into a float. + public static func number(_ v: Double) -> CBOR { + if v.rounded() == v, abs(v) < 9_007_199_254_740_992 { return .int(Int64(v)) } + return .double(v) + } + + /// A text-keyed map, in the order given. + public static func map(_ pairs: KeyValuePairs) -> CBOR { + .map(pairs.map { Entry(.text($0.key), $0.value) }) + } + + public static func map(_ pairs: [(String, CBOR)]) -> CBOR { + .map(pairs.map { Entry(.text($0.0), $0.1) }) + } +} + +// MARK: - Reading + +extension CBOR { + /// The value under a text key, or nil. Unknown keys are simply never + /// asked for, which is the protocol's forward-compatibility rule. + public subscript(key: String) -> CBOR? { + guard case .map(let entries) = self else { return nil } + for e in entries where e.key == .text(key) { return e.value } + return nil + } + + public var int64: Int64? { + switch self { + case .unsigned(let u): return u <= UInt64(Int64.max) ? Int64(u) : nil + case .negative(let n): return n <= UInt64(Int64.max) ? -1 - Int64(n) : nil + case .double(let d): return d.rounded() == d && abs(d) < 9.2e18 ? Int64(d) : nil + case .tagged(_, let inner): return inner.int64 + default: return nil + } + } + + public var int: Int? { int64.flatMap { Int(exactly: $0) } } + + public var double: Double? { + switch self { + case .unsigned(let u): return Double(u) + case .negative(let n): return -1 - Double(n) + case .double(let d): return d + case .tagged(_, let inner): return inner.double + default: return nil + } + } + + public var string: String? { + if case .text(let s) = self { return s } + return nil + } + + public var byteString: Bytes? { + if case .bytes(let b) = self { return b } + return nil + } + + public var bool: Bool? { + if case .bool(let b) = self { return b } + return nil + } + + public var array: [CBOR]? { + if case .array(let a) = self { return a } + return nil + } + + public var entries: [Entry]? { + if case .map(let m) = self { return m } + return nil + } + + public var isNull: Bool { + self == .null || self == .undefined + } + + /// A text-keyed map as a dictionary. Non-text keys are dropped; a + /// duplicate keeps the first, though the box never sends one. + public var textMap: [String: CBOR]? { + guard case .map(let entries) = self else { return nil } + var out = [String: CBOR]() + for e in entries { + if case .text(let k) = e.key, out[k] == nil { out[k] = e.value } + } + return out + } + + public var stringArray: [String]? { + array?.compactMap(\.string) + } +} + +// MARK: - Encoding + +public func encodeCBOR(_ value: CBOR) -> Bytes { + var out = Bytes() + encode(value, into: &out) + return out +} + +private func head(_ major: UInt8, _ n: UInt64, into out: inout Bytes) { + let m = major << 5 + switch n { + case 0..<24: + out.append(m | UInt8(n)) + case 24...0xff: + out.append(m | 24) + out.append(UInt8(n)) + case 0x100...0xffff: + out.append(m | 25) + out.append(UInt8(n >> 8)) + out.append(UInt8(n & 0xff)) + case 0x1_0000...0xffff_ffff: + out.append(m | 26) + for shift in stride(from: 24, through: 0, by: -8) { out.append(UInt8(truncatingIfNeeded: n >> UInt64(shift))) } + default: + out.append(m | 27) + for shift in stride(from: 56, through: 0, by: -8) { out.append(UInt8(truncatingIfNeeded: n >> UInt64(shift))) } + } +} + +private func encode(_ value: CBOR, into out: inout Bytes) { + switch value { + case .unsigned(let u): head(0, u, into: &out) + case .negative(let n): head(1, n, into: &out) + case .bytes(let b): + head(2, UInt64(b.count), into: &out) + out.append(contentsOf: b) + case .text(let s): + let utf8 = Array(s.utf8) + head(3, UInt64(utf8.count), into: &out) + out.append(contentsOf: utf8) + case .array(let items): + head(4, UInt64(items.count), into: &out) + for i in items { encode(i, into: &out) } + case .map(let entries): + head(5, UInt64(entries.count), into: &out) + for e in entries { + encode(e.key, into: &out) + encode(e.value, into: &out) + } + case .tagged(let tag, let inner): + head(6, tag, into: &out) + encode(inner, into: &out) + case .bool(let b): out.append(b ? 0xf5 : 0xf4) + case .null: out.append(0xf6) + case .undefined: out.append(0xf7) + case .double(let d): + // Shortest form that keeps the value exactly, as preferred + // serialisation asks. Lengths never matter to lane 0 — it is padded — + // but agreeing with the other encoders byte for byte is what makes a + // vector test worth having. + let f = Float(d) + if Double(f) == d || d.isNaN { + let half = Float16Bits(f) + if let h = half { + out.append(0xf9) + out.append(UInt8(h >> 8)) + out.append(UInt8(h & 0xff)) + } else { + out.append(0xfa) + let bits = f.bitPattern + for shift in stride(from: 24, through: 0, by: -8) { out.append(UInt8(truncatingIfNeeded: bits >> UInt32(shift))) } + } + } else { + out.append(0xfb) + let bits = d.bitPattern + for shift in stride(from: 56, through: 0, by: -8) { out.append(UInt8(truncatingIfNeeded: bits >> UInt64(shift))) } + } + } +} + +/// The IEEE half-precision bits for a float that half precision holds +/// exactly, or nil when it would lose anything. +private func Float16Bits(_ f: Float) -> UInt16? { + if f.isNaN { return 0x7e00 } + let bits = f.bitPattern + let sign = UInt16((bits >> 16) & 0x8000) + let exp = Int((bits >> 23) & 0xff) + let mant = bits & 0x7f_ffff + if exp == 0xff { return sign | 0x7c00 } // infinity + if exp == 0 && mant == 0 { return sign } + let e = exp - 127 + if e >= -14 && e <= 15 { + // Normal half: 10 mantissa bits. + if mant & 0x1fff != 0 { return nil } + return sign | UInt16(e + 15) << 10 | UInt16(mant >> 13) + } + if e >= -24 && e < -14 { + // Subnormal half. + let full = mant | 0x80_0000 + let shift = UInt32(-e - 14 + 13) + if full & ((1 << shift) - 1) != 0 { return nil } + return sign | UInt16(full >> shift) + } + return nil +} + +// MARK: - Decoding + +/// Decode exactly one value. Trailing bytes are an error: a frame's payload +/// length is explicit, so anything left over means the length was wrong. +public func decodeCBOR(_ bytes: Bytes) throws -> CBOR { + var reader = CBORReader(bytes: bytes) + let value = try reader.read(depth: 0) + if reader.at != bytes.count { + throw CBOR.DecodeError(message: "\(bytes.count - reader.at) trailing bytes") + } + return value +} + +private struct CBORReader { + let bytes: Bytes + var at = 0 + + /// Deep enough for every message in the protocol, shallow enough that a + /// hostile frame cannot exhaust the stack. + static let maxDepth = 64 + + mutating func byte() throws -> UInt8 { + guard at < bytes.count else { throw CBOR.DecodeError(message: "unexpected end") } + defer { at += 1 } + return bytes[at] + } + + mutating func take(_ n: Int) throws -> Bytes { + guard n >= 0, at + n <= bytes.count else { throw CBOR.DecodeError(message: "unexpected end") } + defer { at += n } + return Array(bytes[at.. UInt64 { + switch info { + case 0..<24: return UInt64(info) + case 24: return UInt64(try byte()) + case 25: return try take(2).reduce(0) { $0 << 8 | UInt64($1) } + case 26: return try take(4).reduce(0) { $0 << 8 | UInt64($1) } + case 27: return try take(8).reduce(0) { $0 << 8 | UInt64($1) } + default: throw CBOR.DecodeError(message: "reserved additional info \(info)") + } + } + + func count(_ n: UInt64) throws -> Int { + guard n <= UInt64(bytes.count - at) else { throw CBOR.DecodeError(message: "length \(n) overruns the input") } + return Int(n) + } + + mutating func read(depth: Int) throws -> CBOR { + guard depth < Self.maxDepth else { throw CBOR.DecodeError(message: "nested too deeply") } + let initial = try byte() + let major = initial >> 5 + let info = initial & 0x1f + + if info == 31 { + return try readIndefinite(major: major, depth: depth) + } + + switch major { + case 0: return .unsigned(try argument(info)) + case 1: return .negative(try argument(info)) + case 2: return .bytes(try take(try count(try argument(info)))) + case 3: + let raw = try take(try count(try argument(info))) + guard let s = String(bytes: raw, encoding: .utf8) else { throw CBOR.DecodeError(message: "text is not UTF-8") } + return .text(s) + case 4: + let n = try argument(info) + guard n <= UInt64(bytes.count - at) else { throw CBOR.DecodeError(message: "array overruns the input") } + var items = [CBOR]() + items.reserveCapacity(Int(n)) + for _ in 0.. CBOR { + switch major { + case 2, 3: + var chunks = Bytes() + while true { + if at < bytes.count, bytes[at] == 0xff { at += 1; break } + let chunk = try read(depth: depth + 1) + switch (major, chunk) { + case (2, .bytes(let b)): chunks.append(contentsOf: b) + case (3, .text(let s)): chunks.append(contentsOf: Array(s.utf8)) + default: throw CBOR.DecodeError(message: "mixed chunk in an indefinite string") + } + } + if major == 2 { return .bytes(chunks) } + guard let s = String(bytes: chunks, encoding: .utf8) else { throw CBOR.DecodeError(message: "text is not UTF-8") } + return .text(s) + case 4: + var items = [CBOR]() + while true { + if at < bytes.count, bytes[at] == 0xff { at += 1; break } + items.append(try read(depth: depth + 1)) + } + return .array(items) + case 5: + var entries = [CBOR.Entry]() + while true { + if at < bytes.count, bytes[at] == 0xff { at += 1; break } + let k = try read(depth: depth + 1) + let v = try read(depth: depth + 1) + if entries.contains(where: { $0.key == k }) { throw CBOR.DecodeError(message: "duplicate map key") } + entries.append(CBOR.Entry(k, v)) + } + return .map(entries) + default: + throw CBOR.DecodeError(message: "indefinite length on major type \(major)") + } + } +} + +private func halfToFloat(_ h: UInt16) -> Float { + let sign: Float = h & 0x8000 != 0 ? -1 : 1 + let exp = Int(h >> 10 & 0x1f) + let mant = Float(h & 0x3ff) + if exp == 0 { return sign * mant * pow(2, -24) } + if exp == 31 { return mant == 0 ? sign * .infinity : .nan } + return sign * (1 + mant / 1024) * pow(2, Float(exp - 15)) +} diff --git a/appleApp/FTWKit/Sources/FTWKit/Protocol/Contract.swift b/appleApp/FTWKit/Sources/FTWKit/Protocol/Contract.swift new file mode 100644 index 0000000..86d3b1d --- /dev/null +++ b/appleApp/FTWKit/Sources/FTWKit/Protocol/Contract.swift @@ -0,0 +1,108 @@ +import Foundation + +/// Names from protocol/registry.yaml, in Swift. +/// +/// The registry is one file shared byte for byte with srcfl/ftw and +/// srcfl/ftw-webapp. There is no generator here, so this is written by hand +/// and `ContractTests` reads the registry back and fails when anything has +/// stopped matching. Add nothing here that is not in the registry. +public enum Contract { + /// One object axis, two verb axes: `.`. + public static let scopes = [ + "ftw.live.read", + "ftw.history.read", + "ftw.plan.read", + "ftw.health.read", + "ftw.assets.read", + "ftw.dispatch.write", + "ftw.mode.write", + "ftw.members.read", + "ftw.members.write", + ] + + public static let roleOwner = "owner" + public static let roleViewer = "viewer" + + /// What each role carries, with the registry's `*` expanded. Used only + /// for a box from before roles, which sends no scope list. + public static let roleScopes: [String: [String]] = [ + roleOwner: scopes, + roleViewer: ["ftw.live.read"], + ] + + public static let roleLabels: [String: String] = [ + roleOwner: "Owner", + roleViewer: "Viewer", + ] + + public static let scopeModeWrite = "ftw.mode.write" + + public static let capabilities = [ + "status.core", "status.phases", "status.drivers", + "history.5m", "history.1h", "history.etag", + "cmd.lease", "cmd.precondition", "cmd.readback", + "der.battery", "der.ev", "der.v2x", + "plan.dispatch", "net.webrtc", "price.spot", "api.passthrough", + ] + + public static let capPlanDispatch = "plan.dispatch" + public static let capDerEV = "der.ev" + public static let capPriceSpot = "price.spot" + public static let capAPIPassthrough = "api.passthrough" + + public static let opSetMode = "site.mode.set" + public static let opBatteryHold = "battery.hold" + public static let opLoadpointHold = "loadpoint.hold" + public static let opLoadpointBoost = "loadpoint.boost" + public static let opLoadpointSocSet = "loadpoint.soc.set" + public static let opLoadpointSurplusOnlySet = "loadpoint.surplus_only.set" + + public static let ops = [opSetMode, opBatteryHold, opLoadpointHold, opLoadpointBoost, opLoadpointSocSet, opLoadpointSurplusOnlySet] + + /// Field ids frozen permanently as of v1. + public enum FID { + public static let mode = 1 + public static let gridW = 2 + public static let pvW = 3 + public static let batteryW = 4 + public static let batterySoc = 5 + public static let loadW = 6 + public static let srcGrid = 7 + public static let srcPV = 8 + public static let srcBattery = 9 + /// Present only on a site with a charger. + public static let evW = 10 + } + + /// Whether a code is worth retrying, from the registry's `errors` and + /// `client_errors`. Unknown codes are not: guessing yes offers a button + /// that cannot help. + static let retryable: [String: Bool] = [ + "E_BOOTING": true, + "E_UNKNOWN_OP": false, + "E_CMD_EXPIRED": false, + "E_PRECONDITION": false, + "E_CONFLICT": true, + "E_SCOPE_DENIED": false, + "E_GRANT_REVOKED": false, + "E_LAST_OWNER_PROTECTED": false, + "E_RANGE_TOO_LARGE": false, + "E_UNAVAILABLE": true, + "E_NEEDS_STEP_UP": true, + "E_USE_CMD": false, + "E_UNSUPPORTED_MEDIA": false, + "E_WHOLE_DOCUMENT": false, + "E_LOCAL_ONLY": false, + "E_RESPONSE_TOO_LARGE": false, + "E_NO_ACK": true, + "E_NO_ANSWER": true, + "E_BAD_BODY": false, + ] + + public static func isRetryable(_ code: String) -> Bool { + retryable[code] ?? false + } + + public static let sourceStates = ["live", "lagging", "stale", "down", "never"] + public static let carrierStates = ["webrtc", "relay", "cache", "none"] +} diff --git a/appleApp/FTWKit/Sources/FTWKit/Protocol/Frame.swift b/appleApp/FTWKit/Sources/FTWKit/Protocol/Frame.swift new file mode 100644 index 0000000..50de668 --- /dev/null +++ b/appleApp/FTWKit/Sources/FTWKit/Protocol/Frame.swift @@ -0,0 +1,139 @@ +import Foundation + +/// The frame codec. +/// +/// Each Noise transport message carries exactly one frame: +/// +/// offset field type note +/// 0 ver u8 frame layout version +/// 1 lane u8 0 = telemetry/control, 1 = bulk +/// 2 flags u8 0x02 TRUNC +/// 3 rsvd u8 0 +/// 4 len u16 BE payload bytes +/// 6 payload u8[len] CBOR +/// 6+len pad u8[] zeros to the bucket size +/// +/// The padding is a privacy control. A 1 Hz power stream whose frame length +/// varied with what happened in the house would hand the relay operator the +/// household's load pattern through perfect encryption, so lane 0 frames are +/// always exactly one bucket and never fragment. +public enum Frame { + public static let version: UInt8 = 1 + public static let headerBytes = 6 + public static let maxPayload = 0xffff + + public static let laneControl: UInt8 = 0 + public static let laneBulk: UInt8 = 1 + + /// The delta did not fit its bucket; the rest follows next tick. + public static let flagTrunc: UInt8 = 0x02 + + /// Lane 0 is one fixed size for the life of a session. + public static let controlBuckets = [256, 512] + /// Lane 1 steps, because bulk already leaks that a transfer is happening. + public static let bulkBuckets = [1024, 4096, 16384] + + public struct Decoded: Equatable, Sendable { + public var lane: UInt8 + public var flags: UInt8 + public var envelope: Envelope + + public var truncated: Bool { flags & Frame.flagTrunc != 0 } + } + + public struct FrameError: Error, Equatable { + public let code: String + public let message: String + } + + /// Encode one frame, zero-padded to `bucket`. + /// + /// Throws rather than growing the bucket: a larger frame than asked for + /// would defeat the padding entirely. + public static func encode(lane: UInt8, flags: UInt8 = 0, envelope: Envelope, bucket: Int) throws -> Bytes { + let payload = encodeCBOR(envelope.cbor) + if payload.count > maxPayload { + throw FrameError(code: "E_FRAME_TOO_LARGE", message: "payload \(payload.count) exceeds u16 length field") + } + let needed = headerBytes + payload.count + if needed > bucket { + throw FrameError(code: "E_FRAME_EXCEEDS_BUCKET", message: "frame needs \(needed) bytes, bucket is \(bucket)") + } + var out = Bytes(repeating: 0, count: bucket) + out[0] = version + out[1] = lane + out[2] = flags + out[3] = 0 + out[4] = UInt8(payload.count >> 8) + out[5] = UInt8(payload.count & 0xff) + out.replaceSubrange(headerBytes.. Bytes { + let payload = encodeCBOR(envelope.cbor) + guard let bucket = bulkBuckets.first(where: { $0 >= payload.count + headerBytes }) else { + throw FrameError(code: "E_FRAME_EXCEEDS_BUCKET", message: "bulk payload exceeds the largest bucket") + } + return try encode(lane: laneBulk, envelope: envelope, bucket: bucket) + } + + /// Decode one frame. Everything past `len` is ignored without inspection: + /// the padding is already covered by the AEAD, so checking it would only + /// add a way to reject a frame that is fine. + public static func decode(_ bytes: Bytes) throws -> Decoded { + guard bytes.count >= headerBytes else { + throw FrameError(code: "E_FRAME_SHORT", message: "frame is \(bytes.count) bytes") + } + guard bytes[0] == version else { + throw FrameError(code: "E_FRAME_VERSION", message: "unsupported frame version \(bytes[0])") + } + let len = Int(bytes[4]) << 8 | Int(bytes[5]) + guard headerBytes + len <= bytes.count else { + throw FrameError(code: "E_FRAME_TRUNCATED", message: "declared length \(len) overruns the frame") + } + let value: CBOR + do { + value = try decodeCBOR(Array(bytes[headerBytes..= 0, raw <= Int64(UInt32.max) { + id = UInt32(raw) + } + b = value["b"] + } + + /// Keys in the web app's order: t, id, b. + var cbor: CBOR { + var pairs: [(String, CBOR)] = [("t", .text(t))] + if let id { pairs.append(("id", .unsigned(UInt64(id)))) } + if let b { pairs.append(("b", b)) } + return .map(pairs) + } +} diff --git a/appleApp/FTWKit/Sources/FTWKit/Protocol/HistoryGeometry.swift b/appleApp/FTWKit/Sources/FTWKit/Protocol/HistoryGeometry.swift new file mode 100644 index 0000000..102a014 --- /dev/null +++ b/appleApp/FTWKit/Sources/FTWKit/Protocol/HistoryGeometry.swift @@ -0,0 +1,184 @@ +import Foundation + +/// History geometry: tiles, resolutions and the column packing. The same +/// numbers the box uses, so a tile asked for and a tile built are one tile. +/// +/// Downsampling always happens on the box. When a window is too wide the box +/// clamps to a coarser store and reports `resActual`; when even that is too +/// wide it averages whole buckets and widens `stepMs`. +public enum HistoryGeometry { + public struct Spec: Sendable { + public let stepMs: Double + public let tileSpanMs: Double + public let retentionMs: Double + } + + static let dayMs: Double = 86_400_000 + + /// Mirrors `resolutions` in protocol/registry.yaml. + public static func spec(_ res: Resolution) -> Spec { + switch res { + case .fiveMinutes: return Spec(stepMs: 300_000, tileSpanMs: 43_200_000, retentionMs: 30 * dayMs) + case .hour: return Spec(stepMs: 3_600_000, tileSpanMs: 604_800_000, retentionMs: 730 * dayMs) + } + } + + /// Fine to coarse: the order the box clamps along. + public static let order: [Resolution] = [.fiveMinutes, .hour] + + public static func pointsPerTile(_ res: Resolution) -> Int { + let s = spec(res) + return Int(s.tileSpanMs / s.stepMs) + } + + /// Tiles are epoch-aligned, so two peers never disagree where one starts. + public static func tileStart(_ res: Resolution, _ atMs: Double) -> Double { + let span = spec(res).tileSpanMs + return (atMs / span).rounded(.down) * span + } + + public struct PlannedTile: Equatable, Sendable { + public let tileId: String + public let startMs: Double + public let points: Int + } + + public struct Plan: Equatable, Sendable { + public let res: Resolution + public let stride: Int + public let stepMs: Double + public let tiles: [PlannedTile] + } + + /// Resolution, stride and tile list for a window, deterministically, so + /// the client plans the same tiles the box will send. + public static func plan(_ res: Resolution, fromMs: Double, toMs: Double, maxPoints: Int = 2000) -> Plan { + let cap = max(1, maxPoints) + // Transfer is whole tiles, so what must fit under the cap is the + // tile-aligned span, not the requested one. + func alignedSpan(_ r: Resolution) -> Double { + let first = tileStart(r, fromMs) + let last = tileStart(r, max(fromMs, toMs - 1)) + return last + spec(r).tileSpanMs - first + } + + // Clamp to a coarser store before aggregating: coarser stored data is + // real data, an aggregate of finer data is an average of it. + var chosen = order.last! + for candidate in order where order.firstIndex(of: candidate)! >= order.firstIndex(of: res)! { + chosen = candidate + if alignedSpan(candidate) / spec(candidate).stepMs <= Double(cap) { break } + } + + let base = spec(chosen).stepMs + let total = alignedSpan(chosen) + let n = pointsPerTile(chosen) + let options = (1...n).filter { n % $0 == 0 } + let stride = options.first { total / (base * Double($0)) <= Double(cap) } ?? options.last! + let span = spec(chosen).tileSpanMs + let firstStart = tileStart(chosen, fromMs) + let lastStart = tileStart(chosen, max(fromMs, toMs - 1)) + + var tiles = [PlannedTile]() + var start = firstStart + while start <= lastStart { + tiles.append(PlannedTile(tileId: tileID(chosen, stride: stride, startMs: start), startMs: start, points: n / stride)) + start += span + } + return Plan(res: chosen, stride: stride, stepMs: base * Double(stride), tiles: tiles) + } + + /// The stride is part of a tile's identity: six buckets averaged hold + /// different numbers from the same hours at full detail. + public static func tileID(_ res: Resolution, stride: Int, startMs: Double) -> String { + "\(res.rawValue)/\(stride)/\(Int64(startMs / spec(res).tileSpanMs))" + } + + /// One contiguous int32 LE block per series. + public static func pack(_ columns: [[Int32]]) -> Bytes { + var out = Bytes() + for column in columns { + for v in column { + let u = UInt32(bitPattern: v) + out += [UInt8(u & 0xff), UInt8(u >> 8 & 0xff), UInt8(u >> 16 & 0xff), UInt8(u >> 24)] + } + } + return out + } + + public static func unpack(_ data: Bytes, seriesCount: Int) -> [[Int32]] { + guard seriesCount > 0 else { return [] } + let points = data.count / 4 / seriesCount + guard points > 0 else { return Array(repeating: [], count: seriesCount) } + return (0.. Int32? { + guard let c = names.firstIndex(of: name), index >= 0, index < columns[c].count else { return nil } + let v = columns[c][index] + return v == missingSample ? nil : v + } + + public func time(at index: Int) -> Double { startMs + Double(index) * stepMs } + } + + /// Lay tiles end to end. A missing tile stays missing rather than + /// shifting what follows it: closing over a gap would show the wrong day. + public static func assemble(_ plan: Plan, names: [String], tiles: [String: HistChunk]) -> Frame { + let perTile = plan.tiles.first?.points ?? 0 + let points = perTile * plan.tiles.count + var columns = names.map { _ in [Int32](repeating: missingSample, count: points) } + for (index, planned) in plan.tiles.enumerated() { + guard let tile = tiles[planned.tileId] else { continue } + let unpacked = unpack(tile.data, seriesCount: tile.series.count) + let offset = index * perTile + for (target, name) in names.enumerated() { + guard let s = tile.series.firstIndex(of: name), s < unpacked.count else { continue } + let source = unpacked[s].prefix(perTile) + for (i, v) in source.enumerated() { columns[target][offset + i] = v } + } + } + return Frame(startMs: plan.tiles.first?.startMs ?? 0, stepMs: plan.stepMs, points: points, names: names, columns: columns) + } + + /// Trim a tile-aligned frame to the window asked for. + public static func clip(_ frame: Frame, fromMs: Double, toMs: Double) -> Frame { + guard frame.points > 0 else { return frame } + let first = max(0, Int(((fromMs - frame.startMs) / frame.stepMs).rounded(.down))) + let last = min(frame.points, Int(((toMs - frame.startMs) / frame.stepMs).rounded(.up))) + if first == 0 && last == frame.points { return frame } + if last <= first { return Frame(startMs: frame.startMs, stepMs: frame.stepMs, points: 0, names: frame.names, columns: frame.names.map { _ in [] }) } + return Frame( + startMs: frame.startMs + Double(first) * frame.stepMs, + stepMs: frame.stepMs, + points: last - first, + names: frame.names, + columns: frame.columns.map { Array($0[first.. String { + var h: UInt32 = 0x811c9dc5 + for b in data { + h ^= UInt32(b) + h = h &* 0x01000193 + } + let s = String(h, radix: 16) + return String(repeating: "0", count: 8 - s.count) + s + } +} diff --git a/appleApp/FTWKit/Sources/FTWKit/Protocol/Messages.swift b/appleApp/FTWKit/Sources/FTWKit/Protocol/Messages.swift new file mode 100644 index 0000000..adb6cbc --- /dev/null +++ b/appleApp/FTWKit/Sources/FTWKit/Protocol/Messages.swift @@ -0,0 +1,531 @@ +import Foundation + +/// The message shapes. Names shared with the box come from +/// protocol/registry.yaml, through `Contract`. +/// +/// Every decoder here ignores keys it does not know and defaults what an +/// older box leaves out. Every age is measured against the box's uptime, +/// never its wall clock: a Pi has no RTC and reads 1970 until NTP answers. +public enum Proto { + public static let min = 0 + public static let max = 1 + /// The frozen subset: an app too old for full mode still draws the core. + public static let floor = 0 +} + +public enum BoxMode: String, Sendable { + case full, floor, booting, readonly +} + +public enum SourceState: String, Sendable, Codable, Comparable { + case live, lagging, stale, down, never + + var rank: Int { + switch self { + case .live: return 0 + case .lagging: return 1 + case .stale: return 2 + case .down: return 3 + case .never: return 4 + } + } + + public static func < (a: SourceState, b: SourceState) -> Bool { a.rank < b.rank } +} + +public struct Source: Equatable, Sendable, Codable { + public var kind: String + public var name: String + /// Box uptime at the last good reading. Not wall clock. + public var lastOkMs: Double + public var staleAfterMs: Double + public var state: SourceState + + init?(_ c: CBOR) { + guard c.entries != nil else { return nil } + kind = c["kind"]?.string ?? "" + name = c["name"]?.string ?? "" + lastOkMs = c["lastOkMs"]?.double ?? 0 + staleAfterMs = c["staleAfterMs"]?.double ?? 0 + state = c["state"]?.string.flatMap(SourceState.init(rawValue:)) ?? .never + } + + public init(kind: String, name: String, lastOkMs: Double, staleAfterMs: Double, state: SourceState) { + self.kind = kind + self.name = name + self.lastOkMs = lastOkMs + self.staleAfterMs = staleAfterMs + self.state = state + } +} + +public struct FieldDef: Equatable, Sendable, Codable { + public var name: String + public var unit: String? + /// The source this field's freshness comes from. Nil for fields the box + /// computes itself, such as the mode. + public var srcId: String? + + public init(name: String, unit: String?, srcId: String?) { + self.name = name + self.unit = unit + self.srcId = srcId + } +} + +public struct ModeInfo: Equatable, Sendable, Identifiable { + public var key: String + public var label: String + public var tooltip: String + /// Placement, not permission: primary, advanced or hidden. + public var tier: String + public var id: String { key } + + public init(key: String, label: String, tooltip: String, tier: String) { + self.key = key + self.label = label + self.tooltip = tooltip + self.tier = tier + } +} + +public struct BootProgress: Equatable, Sendable { + public var phase: String + public var pct: Int + public var etaMs: Double? +} + +public struct BoxInfo: Equatable, Sendable { + public var id: String + public var build: String + public var tz: String +} + +public struct HelloOk: Equatable, Sendable { + public var proto: Int + public var mode: BoxMode + public var box: BoxInfo + public var clockSource: String + public var syncedAtMs: Double? + public var uptimeMs: Double + public var role: String? + public var scopes: [String]? + public var caps: [String] + public var modes: [ModeInfo] + public var boot: BootProgress? + public var hint: String? + public var subscribed: Bool + + init(_ c: CBOR) { + proto = c["proto"]?.int ?? Proto.max + mode = c["mode"]?.string.flatMap(BoxMode.init(rawValue:)) ?? .full + let b = c["box"] + box = BoxInfo(id: b?["id"]?.string ?? "", build: b?["build"]?.string ?? "", tz: b?["tz"]?.string ?? "") + let clock = c["clock"] + clockSource = clock?["source"]?.string ?? "none" + // Go sends 0 for never synced; 0 is a real 1970 timestamp, so it + // means nil here. + let synced = clock?["syncedAtMs"]?.double ?? 0 + syncedAtMs = synced > 0 ? synced : nil + uptimeMs = clock?["uptimeMs"]?.double ?? 0 + role = c["role"]?.string.flatMap { $0.isEmpty ? nil : $0 } + scopes = c["scopes"]?.stringArray + caps = c["caps"]?.stringArray ?? [] + modes = (c["modes"]?.array ?? []).compactMap { m in + guard let key = m["key"]?.string else { return nil } + return ModeInfo(key: key, label: m["label"]?.string ?? key, tooltip: m["tooltip"]?.string ?? "", tier: m["tier"]?.string ?? "advanced") + } + if let boot = c["boot"], boot.entries != nil { + self.boot = BootProgress(phase: boot["phase"]?.string ?? "", pct: boot["pct"]?.int ?? 0, etaMs: boot["etaMs"]?.double) + } + hint = c["hint"]?.string + subscribed = c["subscribed"]?.bool ?? false + } +} + +/// Fields arrive as a text-keyed map of integers: `{"2": -3000}`. +func decodeFields(_ c: CBOR?) -> [Int: Double] { + var out = [Int: Double]() + for e in c?.entries ?? [] { + let key: Int? + switch e.key { + case .text(let s): key = Int(s) + default: key = e.key.int + } + if let key, let v = e.value.double { out[key] = v } + } + return out +} + +func decodeSources(_ c: CBOR?) -> [String: Source] { + var out = [String: Source]() + for e in c?.entries ?? [] { + if let k = e.key.string, let s = Source(e.value) { out[k] = s } + } + return out +} + +func decodeDict(_ c: CBOR?) -> [Int: FieldDef] { + var out = [Int: FieldDef]() + for e in c?.entries ?? [] { + guard let k = e.key.string.flatMap(Int.init) ?? e.key.int else { continue } + out[k] = FieldDef( + name: e.value["name"]?.string ?? "", + unit: e.value["unit"]?.string, + srcId: e.value["srcId"]?.string + ) + } + return out +} + +public struct Snap: Sendable { + public var uptimeMs: Double + public var controlRev: UInt64 + public var dict: [Int: FieldDef] + public var fields: [Int: Double] + public var sources: [String: Source] + public var dispatchBlockedBy: [String] + + init(_ c: CBOR) { + uptimeMs = c["uptimeMs"]?.double ?? 0 + controlRev = UInt64(max(0, c["controlRev"]?.int64 ?? 0)) + dict = decodeDict(c["dict"]) + fields = decodeFields(c["fields"]) + sources = decodeSources(c["sources"]) + dispatchBlockedBy = c["dispatchBlockedBy"]?.stringArray ?? [] + } +} + +public struct Delta: Sendable { + public var seq: UInt64 + public var uptimeMs: Double + public var fields: [Int: Double] + public var sources: [String: Source]? + public var dispatchBlockedBy: [String]? + + init(_ c: CBOR) { + seq = UInt64(max(0, c["seq"]?.int64 ?? 0)) + uptimeMs = c["uptimeMs"]?.double ?? 0 + fields = decodeFields(c["fields"]) + sources = c["sources"].map { decodeSources($0) } + dispatchBlockedBy = c["dispatchBlockedBy"]?.stringArray + } +} + +// MARK: - Plan and prices + +public struct PlanSlot: Equatable, Sendable, Identifiable { + /// Wall clock: these are future times a person reads. + public var startMs: Double + public var durationMs: Double + /// Signed watts at the battery, site convention: positive charges. + public var batteryW: Double + /// Expected import at the meter; negative means expected export. + public var gridW: Double + /// Import price in minor units per kWh, nil when unknown. + public var priceMinor: Double? + /// A stable code, never prose. The app owns every word. + public var reason: String + public var id: Double { startMs } + + public init(startMs: Double, durationMs: Double, batteryW: Double, gridW: Double, priceMinor: Double?, reason: String) { + self.startMs = startMs + self.durationMs = durationMs + self.batteryW = batteryW + self.gridW = gridW + self.priceMinor = priceMinor + self.reason = reason + } +} + +public struct Plan: Equatable, Sendable { + public var rev: UInt64 + public var uptimeMs: Double + public var slots: [PlanSlot] + /// The planner could not run and the box fell back. "Nothing scheduled" + /// and "we do not know what is scheduled" are different sentences. + public var stale: Bool + public var ceilingW: Double? + + public init(rev: UInt64, uptimeMs: Double, slots: [PlanSlot], stale: Bool, ceilingW: Double?) { + self.rev = rev + self.uptimeMs = uptimeMs + self.slots = slots + self.stale = stale + self.ceilingW = ceilingW + } + + init(_ c: CBOR) { + rev = UInt64(max(0, c["rev"]?.int64 ?? 0)) + uptimeMs = c["uptimeMs"]?.double ?? 0 + slots = (c["slots"]?.array ?? []).compactMap { s in + guard let start = s["startMs"]?.double else { return nil } + return PlanSlot( + startMs: start, + durationMs: s["durationMs"]?.double ?? 900_000, + batteryW: s["batteryW"]?.double ?? 0, + gridW: s["gridW"]?.double ?? 0, + priceMinor: s["priceMinor"]?.double, + reason: s["reason"]?.string ?? "idle" + ) + } + stale = c["stale"]?.bool ?? false + ceilingW = c["ceilingW"]?.double + } +} + +public struct PriceSlot: Equatable, Sendable, Identifiable { + public var startMs: Double + public var durationMs: Double + /// Integer minor units per kWh. Money never crosses as a float. + public var spotMinor: Double + /// What the household pays, tariff and tax included, computed by the box. + public var totalMinor: Double + public var id: Double { startMs } + + public init(startMs: Double, durationMs: Double, spotMinor: Double, totalMinor: Double) { + self.startMs = startMs + self.durationMs = durationMs + self.spotMinor = spotMinor + self.totalMinor = totalMinor + } +} + +public struct Prices: Equatable, Sendable { + public var zone: String + public var currency: String + public var slots: [PriceSlot] + /// The answer does not cover the window asked for. Which of the three + /// shapes it is has to be read off the slots. + public var stale: Bool + + public init(zone: String, currency: String, slots: [PriceSlot], stale: Bool) { + self.zone = zone + self.currency = currency + self.slots = slots + self.stale = stale + } + + init(_ c: CBOR) { + zone = c["zone"]?.string ?? "" + currency = c["currency"]?.string ?? "SEK" + slots = (c["slots"]?.array ?? []).compactMap { s in + guard let start = s["startMs"]?.double else { return nil } + return PriceSlot( + startMs: start, + durationMs: s["durationMs"]?.double ?? 3_600_000, + spotMinor: s["spotMinor"]?.double ?? 0, + totalMinor: s["totalMinor"]?.double ?? s["spotMinor"]?.double ?? 0 + ) + } + stale = c["stale"]?.bool ?? false + } +} + +// MARK: - History + +public enum Resolution: String, Sendable, CaseIterable { + case fiveMinutes = "5m" + case hour = "1h" +} + +public struct HistQuery: Sendable { + public var series: [String] + public var res: Resolution + public var fromMs: Double + public var toMs: Double + public var have: [(tileId: String, etag: String)] + public var maxPoints: Int? + + var cbor: CBOR { + var pairs: [(String, CBOR)] = [ + ("series", .array(series.map { .text($0) })), + ("res", .text(res.rawValue)), + // Whole milliseconds: the box reads these into int64 and drops a + // query whose times carry a fraction. + ("fromMs", .ms(fromMs)), + ("toMs", .ms(toMs)), + ] + if !have.isEmpty { + pairs.append(("have", .array(have.map { .map([("tileId", .text($0.tileId)), ("etag", .text($0.etag))]) }))) + } + if let maxPoints { pairs.append(("maxPoints", .int(maxPoints))) } + return .map(pairs) + } +} + +public struct HistChunk: Sendable { + public var tileId: String + public var etag: String + public var res: Resolution + public var startMs: Double + public var stepMs: Double + public var series: [String] + /// Column-packed int32 little-endian, one block per series. + public var data: Bytes + /// The trailing tile is still filling; never cache it. + public var partial: Bool + + init?(_ c: CBOR) { + guard let tileId = c["tileId"]?.string else { return nil } + self.tileId = tileId + etag = c["etag"]?.string ?? "" + res = c["res"]?.string.flatMap(Resolution.init(rawValue:)) ?? .fiveMinutes + startMs = c["startMs"]?.double ?? 0 + stepMs = c["stepMs"]?.double ?? 300_000 + series = c["series"]?.stringArray ?? [] + data = c["data"]?.byteString ?? [] + partial = c["partial"]?.bool ?? false + } + + public init(tileId: String, etag: String, res: Resolution, startMs: Double, stepMs: Double, series: [String], data: Bytes, partial: Bool) { + self.tileId = tileId + self.etag = etag + self.res = res + self.startMs = startMs + self.stepMs = stepMs + self.series = series + self.data = data + self.partial = partial + } +} + +public struct HistGap: Equatable, Sendable { + public var fromMs: Double + public var toMs: Double + public var reason: String +} + +public struct HistEnd: Sendable { + /// What the box actually served, which may be coarser than asked for. + public var resActual: Resolution + public var gaps: [HistGap] + + init(_ c: CBOR) { + resActual = c["resActual"]?.string.flatMap(Resolution.init(rawValue:)) ?? .fiveMinutes + gaps = (c["gaps"]?.array ?? []).compactMap { g in + guard let from = g["fromMs"]?.double, let to = g["toMs"]?.double else { return nil } + return HistGap(fromMs: from, toMs: to, reason: g["reason"]?.string ?? "no_data") + } + } +} + +/// Distinct from zero, which is a real reading. +public let missingSample = Int32.min + +// MARK: - The box's own API + +public enum APIMethod: String, Sendable { + case get = "GET", head = "HEAD", post = "POST", put = "PUT", patch = "PATCH", delete = "DELETE" +} + +/// A request against the box's own HTTP API, carried in the session. +/// +/// There is no headers field, deliberately: the caller's identity rides on +/// the request context inside the box, put there by the session that +/// authenticated it, so no byte this app sends becomes a claim about who is +/// asking. The query is parsed, never a raw string. +public struct APIRequest: Sendable { + public var method: APIMethod + public var path: String + public var query: [String: String] + public var body: Bytes? + public var stepUp: Bool + + public init(method: APIMethod, path: String, query: [String: String] = [:], body: Bytes? = nil, stepUp: Bool = false) { + self.method = method + self.path = path + self.query = query + self.body = body + self.stepUp = stepUp + } + + var cbor: CBOR { + var pairs: [(String, CBOR)] = [ + ("maxBytes", .int(Session.apiMaxBytes)), + ("method", .text(method.rawValue)), + ("path", .text(path)), + ] + if !query.isEmpty { + pairs.append(("query", .map(query.sorted { $0.key < $1.key }.map { ($0.key, CBOR.text($0.value)) }))) + } + if let body { pairs.append(("body", .bytes(body))) } + if stepUp { pairs.append(("stepUp", .bool(true))) } + return .map(pairs) + } +} + +public struct APIResponse: Sendable { + public var status: Int + public var headers: [String: String] + public var body: Bytes +} + +// MARK: - Commands + +public struct Guard: Sendable { + public var fid: Int + public var op: String + public var value: Double + + public init(fid: Int, op: String, value: Double) { + self.fid = fid + self.op = op + self.value = value + } +} + +public struct CmdResult: Equatable, Sendable { + public enum State: String, Sendable { + case applied, rejected, expired, superseded, unconfirmed + } + + public var cmdId: String + public var state: State + /// Present when the driver read the value back. The real proof. + public var observedValue: Double? + public var errorCode: String? + public var errorArgs: [String: CBOR] + + public init(cmdId: String, state: State, observedValue: Double? = nil, errorCode: String? = nil, errorArgs: [String: CBOR] = [:]) { + self.cmdId = cmdId + self.state = state + self.observedValue = observedValue + self.errorCode = errorCode + self.errorArgs = errorArgs + } + + init(_ c: CBOR) { + cmdId = c["cmdId"]?.string ?? "" + state = c["state"]?.string.flatMap(State.init(rawValue:)) ?? .rejected + observedValue = c["observed"]?["value"]?.double + errorCode = c["error"]?["code"]?.string + errorArgs = c["error"]?["args"]?.textMap ?? [:] + } +} + +// MARK: - Errors and teardown + +public struct ErrorMsg: Equatable, Sendable { + public var code: String + public var retryable: Bool + public var retryAfterMs: Double? + public var args: [String: CBOR] + + public init(code: String, retryable: Bool? = nil, args: [String: CBOR] = [:]) { + self.code = code + self.retryable = retryable ?? Contract.isRetryable(code) + self.args = args + } + + init(_ c: CBOR) { + code = c["code"]?.string ?? "E_UNKNOWN" + retryable = c["retryable"]?.bool ?? Contract.isRetryable(code) + retryAfterMs = c["retryAfterMs"]?.double + args = c["args"]?.textMap ?? [:] + } +} + +public enum TerminateReason: String, Sendable { + case revoked, epochChanged = "epoch_changed", boxShutdown = "box_shutdown", superseded +} diff --git a/appleApp/FTWKit/Sources/FTWKit/Protocol/Session.swift b/appleApp/FTWKit/Sources/FTWKit/Protocol/Session.swift new file mode 100644 index 0000000..7ae4e18 --- /dev/null +++ b/appleApp/FTWKit/Sources/FTWKit/Protocol/Session.swift @@ -0,0 +1,775 @@ +import Foundation + +/// The session: handshake, subscription and the field register. +/// +/// Owns exactly one carrier at a time and turns frames into state. +/// Everything above reads state; nothing above touches a frame. +/// +/// Freshness is two facts, never one: `carrier`, how frames are reaching us, +/// and `sources`, whether the box's own devices are answering. +public enum SessionPhase: String, Sendable { + case idle, handshaking, subscribing, streaming, booting, terminated, failed +} + +public struct SessionState: Sendable { + public var phase: SessionPhase = .idle + public var carrier: CarrierKind = .none + public var proto: Int = Proto.max + public var mode: BoxMode = .full + public var caps: Set = [] + /// What this enrolment may do, as the box named it. Read only to decide + /// what to draw. A box from before roles sends nothing and treats every + /// paired phone as an owner, so that is what absent means. + public var role: String = Contract.roleOwner + /// That role expanded, as the box expanded it. What a control is checked + /// against; `role` is what a sentence names. + public var scopes: Set = Set(Contract.roleScopes[Contract.roleOwner] ?? []) + /// Whether the box has answered a hello since the app opened. Before + /// that, role and caps are this app's opening assumptions, and a screen + /// that states something about the box from them would be inventing it. + public var heardFromBox = false + public var box: BoxInfo? + /// Box uptime at the last frame. All ages are deltas against this. + public var uptimeMs: Double = 0 + public var controlRev: UInt64 = 0 + public var fields: [Int: Double] = [:] + public var dict: [Int: FieldDef] = [:] + public var sources: [String: Source] = [:] + public var dispatchBlockedBy: [String] = [] + public var boot: BootProgress? + public var lastError: ErrorMsg? + public var terminated: TerminateReason? + /// The box says this app is too old for everything. + public var needsUpdate = false + /// What the box intends to do. The box pushes a fresh one unasked after + /// a mode change, so it lives here rather than only as an answer. + public var plan: Plan? + /// The last delta could not carry every changed field. Not a fault. + public var truncated = false + /// Every mode the box accepts, in its order. Field 1 indexes this list. + public var modes: [ModeInfo] = [] + + public init() {} +} + +public struct Subscription: Equatable, Sendable { + /// Lane 0 bucket, fixed for the session. + public var bucket: Int = 512 + /// 1 while someone can see it, 0.2 while hidden. + public var hz: Double = 1 + + public init(bucket: Int = 512, hz: Double = 1) { + self.bucket = bucket + self.hz = hz + } + + var cbor: CBOR { .map([("bucket", .int(bucket)), ("hz", .number(hz))]) } +} + +/// Errors carry what a screen needs, never a code it would have to render. +public enum SessionError: Error, Equatable { + case noCarrier + case carrierClosed + case timedOut(String) + case outOfOrder + case noStatus + case queueTimedOut +} + +/// The box answered a request with a stable code instead of an answer. +public struct BoxRefusal: Error, Equatable { + public let detail: ErrorMsg +} + +/// A command's fate as a sentence the person can act on. +public struct CommandError: Error, Equatable, HelpfulError { + public let code: String + public let help: String +} + +@MainActor +public final class Session { + /// History over a relay can be dozens of bulk frames; the point is that + /// the request always settles, not that it fails fast. + public static let historyTimeoutMs: Double = 20_000 + public static let apiTimeoutMs: Double = 20_000 + public static let planTimeoutMs: Double = 8_000 + public static let priceTimeoutMs: Double = 8_000 + /// A box that is starting refuses a subscription and does not announce + /// when it is ready, so the session asks again on its own. + public static let bootRetryMs: Double = 5_000 + /// Two minutes of box uptime: survives a slow relay, and a command queued + /// in a tunnel is refused rather than acted on as though new. + public static let cmdValidForMs: Double = 120_000 + public static let cmdAckTimeoutMs: Double = 5_000 + public static let cmdConfirmTimeoutMs: Double = 15_000 + public nonisolated static let apiMaxBytes = 8 * 1024 * 1024 + + public private(set) var state = SessionState() { + didSet { onChange?(state) } + } + + /// One listener: the site model. Called after every change. + public var onChange: (@MainActor (SessionState) -> Void)? + + private let build: String + private let ua: String + private let locales: [String] + private let scheduler: Scheduler + private var carrier: Carrier? + private var subscription: Subscription + private var helloSub: Subscription? + private var nextRequestID: UInt32 = 1 + private var bootRetry: Cancellable? + + private struct PendingHistory { + var onChunk: @MainActor (HistChunk) -> Void + var continuation: CheckedContinuation + var timer: Cancellable? + } + + private struct PendingSingle { + var continuation: CheckedContinuation + var timer: Cancellable? + } + + private struct PendingAPI { + var continuation: CheckedContinuation + var timer: Cancellable? + var head: (status: Int, headers: [String: String])? + var chunks: Bytes = [] + var nextSeq: Int = 0 + } + + private struct PendingCommand { + var continuation: CheckedContinuation + var ackTimer: Cancellable + var confirmTimer: Cancellable + var acked = false + } + + private var pendingHistory: [UInt32: PendingHistory] = [:] + private var pendingPlan: [UInt32: PendingSingle] = [:] + private var pendingPrices: [UInt32: PendingSingle] = [:] + private var pendingAPI: [UInt32: PendingAPI] = [:] + private var pendingCommands: [String: PendingCommand] = [:] + + // The api queue: one call on the wire at a time, because that is how + // many the box serves. + private var apiBusy = false + private var apiWaiters: [(id: Int, continuation: CheckedContinuation, timer: Cancellable)] = [] + private var apiWaiterSeq = 0 + private var apiGeneration = 0 + + public init(build: String, ua: String = "native", locales: [String] = ["en"], subscription: Subscription = Subscription(), scheduler: Scheduler) { + self.build = build + self.ua = ua + self.locales = locales + self.subscription = subscription + self.scheduler = scheduler + } + + // MARK: Lifecycle + + /// Seed state from the cache before any carrier exists. The readings + /// were true when captured and the band says how long ago; carrier stays + /// `cache`, which is a carrier and not a failure. + /// + /// The cache read races the connect. Landing mid-handshake, only the + /// data paints: the phase, carrier and clock belong to the connection. + public func restore(_ snapshot: CachedSnapshot) { + switch state.phase { + case .streaming: + return + case .handshaking, .subscribing, .booting: + if !state.fields.isEmpty { return } + var s = state + snapshot.apply(to: &s, includeClock: false) + state = s + default: + var s = state + snapshot.apply(to: &s, includeClock: true) + s.phase = .idle + s.carrier = .cache + state = s + } + } + + /// Attach a carrier and start the handshake. Replaces any current one. + public func connect(_ next: Carrier) { + detach() + // Keep the readings, but drop the old transport claim now: an old + // stream must never read as live while the new carrier dials. + var s = state + s.phase = .idle + s.carrier = .none + state = s + carrier = next + next.setHandlers( + onFrame: { [weak self] bytes in self?.onFrame(bytes) }, + onStatus: { [weak self, weak next] status in + guard let self, let next, self.carrier === next else { return } + self.onCarrierStatus(status, kind: next.kind) + } + ) + if next.status.isOpen { + onCarrierStatus(next.status, kind: next.kind) + } + } + + public func close() { + detach() + var s = state + s.phase = .idle + s.carrier = .none + state = s + } + + /// Match lane 0 to whether anybody can see it. The bucket stays fixed. + public func setTelemetryHz(_ hz: Double) { + if subscription.hz == hz { return } + subscription.hz = hz + if state.phase == .subscribing || state.phase == .streaming { sendSub() } + } + + /// Ask the carrier stack to replace a path that may have slept stale. + @discardableResult + public func wake() -> Bool { + guard let carrier else { return false } + carrier.wake() + return true + } + + public var hasCarrier: Bool { carrier != nil } + + // MARK: Freshness + + /// Age of a source's last good reading, in ms of box uptime. Nil when the + /// two numbers come from different boots: unknown, not "just now". + public func ageOf(_ srcId: String) -> Double? { + guard let src = state.sources[srcId] else { return nil } + let age = state.uptimeMs - src.lastOkMs + return age < 0 ? nil : age + } + + /// Worst state across the given sources. + public func worstSourceState(_ ids: [String]) -> SourceState { + ids.map { state.sources[$0]?.state ?? .never }.max() ?? .live + } + + // MARK: Requests + + private func takeRequestID() -> UInt32 { + let id = nextRequestID + // u32, and wrapping is harmless: a request that old has settled. + nextRequestID = nextRequestID == UInt32.max ? 1 : nextRequestID + 1 + return id + } + + /// A history window. Chunks arrive as they land; the call returns on + /// `hist.end`. Bulk lane: a query carrying a `have` list varies in length. + public func history(_ query: HistQuery, onChunk: @escaping @MainActor (HistChunk) -> Void) async throws -> HistEnd { + guard carrier != nil else { throw SessionError.noCarrier } + let id = takeRequestID() + let frame = try Frame.encodeBulk(envelope: Envelope(t: "hist.query", id: id, b: query.cbor)) + // Registered before the frame leaves, so an answer can never arrive + // for a request nobody is holding. + return try await withCheckedThrowingContinuation { cont in + pendingHistory[id] = PendingHistory(onChunk: onChunk, continuation: cont, timer: nil) + armHistory(id) + carrier?.send(frame) + } + } + + public func plan() async throws -> Plan { + guard carrier != nil else { throw SessionError.noCarrier } + let id = takeRequestID() + let frame = try Frame.encodeBulk(envelope: Envelope(t: "plan.get", id: id)) + return try await withCheckedThrowingContinuation { cont in + let timer = scheduler.after(Self.planTimeoutMs) { [weak self] in + guard let p = self?.pendingPlan.removeValue(forKey: id) else { return } + p.continuation.resume(throwing: SessionError.timedOut("plan")) + } + pendingPlan[id] = PendingSingle(continuation: cont, timer: timer) + carrier?.send(frame) + } + } + + /// Prices across a window. Wall clock, unlike every age: prices are + /// about hours a person plans around. + public func prices(fromMs: Double, toMs: Double) async throws -> Prices { + guard carrier != nil else { throw SessionError.noCarrier } + let id = takeRequestID() + let frame = try Frame.encodeBulk(envelope: Envelope(t: "price.get", id: id, b: .map([("fromMs", .ms(fromMs)), ("toMs", .ms(toMs))]))) + return try await withCheckedThrowingContinuation { cont in + let timer = scheduler.after(Self.priceTimeoutMs) { [weak self] in + guard let p = self?.pendingPrices.removeValue(forKey: id) else { return } + p.continuation.resume(throwing: SessionError.timedOut("prices")) + } + pendingPrices[id] = PendingSingle(continuation: cont, timer: timer) + carrier?.send(frame) + } + } + + /// Call the box's own HTTP API. A 404 is an answer here, not a failure; + /// `BoxRefusal` means the passthrough refused and no handler ran. + /// + /// One call on the wire at a time. The queue is released on settle, not + /// success, and a call never crosses a disconnect into a new session. + public func api(_ req: APIRequest) async throws -> APIResponse { + let generation = apiGeneration + try await acquireAPISlot() + defer { releaseAPISlot() } + for attempt in 0... { + if generation != apiGeneration { throw SessionError.carrierClosed } + do { + return try await dispatchAPI(req) + } catch let refusal as BoxRefusal { + // The box can send its last frame before releasing its slot. + // Only this explicit refusal proves no handler ran; never + // retry anything else. + guard refusal.detail.code == "E_UNAVAILABLE", + refusal.detail.args["reason"]?.string == "busy", + refusal.detail.retryable, attempt < 3 else { throw refusal } + try await sleep(250 * pow(2, Double(attempt))) + } + } + throw SessionError.carrierClosed + } + + private func sleep(_ ms: Double) async throws { + try await withCheckedThrowingContinuation { (cont: CheckedContinuation) in + scheduler.after(ms) { cont.resume() } + } + } + + private func acquireAPISlot() async throws { + if !apiBusy { + apiBusy = true + return + } + apiWaiterSeq += 1 + let mine = apiWaiterSeq + try await withCheckedThrowingContinuation { (cont: CheckedContinuation) in + let timer = scheduler.after(Self.apiTimeoutMs) { [weak self] in + guard let self, let i = self.apiWaiters.firstIndex(where: { $0.id == mine }) else { return } + self.apiWaiters.remove(at: i) + cont.resume(throwing: SessionError.queueTimedOut) + } + apiWaiters.append((mine, cont, timer)) + } + } + + private func releaseAPISlot() { + if apiWaiters.isEmpty { + apiBusy = false + return + } + let next = apiWaiters.removeFirst() + next.timer.cancel() + next.continuation.resume() + } + + private func dispatchAPI(_ req: APIRequest) async throws -> APIResponse { + guard let carrier else { throw SessionError.noCarrier } + if case .closed = carrier.status { throw SessionError.carrierClosed } + let id = takeRequestID() + let frame = try Frame.encodeBulk(envelope: Envelope(t: "api.req", id: id, b: req.cbor)) + return try await withCheckedThrowingContinuation { cont in + pendingAPI[id] = PendingAPI(continuation: cont) + armAPI(id) + carrier.send(frame) + } + } + + /// Express an intent and follow it to its outcome. Three deadlines, + /// because they are three events: no ack means it never reached the box; + /// ack but no result means the hardware has not confirmed; a result is + /// what actually happened. + public func command(_ op: String, args: [(String, CBOR)], guards: [Guard] = []) async throws -> CmdResult { + guard let carrier else { throw SessionError.noCarrier } + let cmdId = Self.uuidv7(nowMs: scheduler.nowMs) + let body = CBOR.map([ + ("cmdId", .text(cmdId)), + ("op", .text(op)), + ("args", .map(args)), + // Box uptime, the only clock both ends agree on. + ("notValidAfterMs", .ms(state.uptimeMs + Self.cmdValidForMs)), + ("expect", .map([ + ("rev", .unsigned(state.controlRev)), + ("guards", .array(guards.map { .map([("fid", .int($0.fid)), ("op", .text($0.op)), ("value", .number($0.value))]) })), + ])), + ]) + let frame = try Frame.encode(lane: Frame.laneControl, envelope: Envelope(t: "cmd", b: body), bucket: subscription.bucket) + return try await withCheckedThrowingContinuation { cont in + let ackTimer = scheduler.after(Self.cmdAckTimeoutMs) { [weak self] in + guard let self, let p = self.pendingCommands[cmdId], !p.acked else { return } + self.pendingCommands[cmdId] = nil + p.confirmTimer.cancel() + p.continuation.resume(throwing: CommandError(code: "E_NO_ACK", help: "That didn't reach your box. Try again.")) + } + let confirmTimer = scheduler.after(Self.cmdConfirmTimeoutMs) { [weak self] in + guard let self, let p = self.pendingCommands[cmdId], p.acked else { return } + self.pendingCommands[cmdId] = nil + // Accepted, never confirmed: not a failure and not a success, + // and the screen has to be able to say so. + p.continuation.resume(returning: CmdResult(cmdId: cmdId, state: .unconfirmed)) + } + pendingCommands[cmdId] = PendingCommand(continuation: cont, ackTimer: ackTimer, confirmTimer: confirmTimer) + carrier.send(frame) + } + } + + /// UUIDv7: time-ordered, so the box can expire idempotency keys by prefix. + static func uuidv7(nowMs: Double) -> String { + var bytes = randomBytes(16) + let ms = UInt64(max(0, nowMs)) + for i in 0..<6 { bytes[i] = UInt8(truncatingIfNeeded: ms >> UInt64(8 * (5 - i))) } + bytes[6] = (bytes[6] & 0x0f) | 0x70 + bytes[8] = (bytes[8] & 0x3f) | 0x80 + let h = bytes.hex + let c = Array(h) + return "\(String(c[0..<8]))-\(String(c[8..<12]))-\(String(c[12..<16]))-\(String(c[16..<20]))-\(String(c[20...]))" + } + + // MARK: Frames in + + private func onCarrierStatus(_ status: CarrierStatus, kind: CarrierKind) { + switch status { + case .open: + var s = state + s.phase = .handshaking + s.carrier = kind + state = s + sendHello() + case .closed: + // Losing the carrier does not clear the readings. They are still + // true, just older, and the band says so. + var s = state + s.phase = .failed + s.carrier = .none + state = s + bootRetry?.cancel() + // The ordinary way a carrier goes away: it reconnects inside + // itself, keeping its handlers. Every request in flight ends now, + // so no view waits out a deadline against a reply that cannot come. + settlePending() + case .connecting: + break + } + } + + private func onFrame(_ bytes: Bytes) { + // A frame that does not parse is not a reason to tear down a working + // session; the next one may be fine. + guard let frame = try? Frame.decode(bytes) else { return } + let env = frame.envelope + let body = env.b ?? CBOR.emptyMap + switch env.t { + case "hello_ok": onHelloOk(HelloOk(body)) + case "snap": onSnap(Snap(body)) + case "delta": onDelta(Delta(body), truncated: frame.truncated) + case "tick": + var s = state + s.uptimeMs = body["uptimeMs"]?.double ?? s.uptimeMs + state = s + case "hist.chunk": + guard let id = env.id, let chunk = HistChunk(body), let pending = pendingHistory[id] else { return } + // A window still arriving is not a window gone quiet. + armHistory(id) + pending.onChunk(chunk) + case "hist.end": + guard let id = env.id, let pending = pendingHistory.removeValue(forKey: id) else { return } + pending.timer?.cancel() + pending.continuation.resume(returning: HistEnd(body)) + case "plan": + let plan = Plan(body) + var s = state + s.plan = plan + state = s + if let id = env.id, let pending = pendingPlan.removeValue(forKey: id) { + pending.timer?.cancel() + pending.continuation.resume(returning: plan) + } + case "price": + guard let id = env.id, let pending = pendingPrices.removeValue(forKey: id) else { return } + pending.timer?.cancel() + pending.continuation.resume(returning: Prices(body)) + case "api.head": onAPIHead(env.id, body) + case "api.chunk": onAPIChunk(env.id, body) + case "api.end": onAPIEnd(env.id, body) + case "cmd.ack": + guard let cmdId = body["cmdId"]?.string, var p = pendingCommands[cmdId] else { return } + p.ackTimer.cancel() + p.acked = true + pendingCommands[cmdId] = p + case "cmd.result": + let result = CmdResult(body) + guard let p = pendingCommands.removeValue(forKey: result.cmdId) else { return } + p.ackTimer.cancel() + p.confirmTimer.cancel() + p.continuation.resume(returning: result) + case "error": onError(ErrorMsg(body), id: env.id) + case "session.terminate": + var s = state + s.phase = .terminated + s.terminated = body["reason"]?.string.flatMap(TerminateReason.init(rawValue:)) ?? .superseded + s.carrier = .none + state = s + detach() + default: + // Unknown types are ignored: a newer box talking to this app. + break + } + } + + private func onHelloOk(_ b: HelloOk) { + var s = state + s.proto = b.proto + s.mode = b.mode + s.caps = Set(b.caps) + s.role = b.role ?? Contract.roleOwner + // The box's own expansion, never this app's, except for a box from + // before roles that sends no list at all. + s.scopes = Set(b.scopes ?? Contract.roleScopes[b.role ?? Contract.roleOwner] ?? []) + s.heardFromBox = true + s.modes = b.modes + s.box = b.box + s.uptimeMs = b.uptimeMs + s.boot = b.boot + s.needsUpdate = b.hint == "app_update" || b.proto == Proto.floor + if b.mode == .booting { + s.phase = .booting + state = s + bootRetry?.cancel() + bootRetry = scheduler.after(Self.bootRetryMs) { [weak self] in self?.sendHello() } + return + } + s.phase = .subscribing + state = s + if b.subscribed { + // Visibility can change while hello crosses the relay; send only + // if the ask has moved since then. + if helloSub != subscription { sendSub() } + return + } + // An older box ignored hello.sub and needs the separate exchange. + sendSub() + } + + private func onSnap(_ b: Snap) { + var s = state + s.phase = .streaming + if let carrier { s.carrier = carrier.kind } + s.uptimeMs = b.uptimeMs + s.controlRev = b.controlRev + s.dict = b.dict + s.fields = b.fields + s.sources = b.sources + s.dispatchBlockedBy = b.dispatchBlockedBy + state = s + } + + private func onDelta(_ b: Delta, truncated: Bool) { + // A gap means frames were lost. The values held are still true, so + // what arrived is applied rather than blanking the screen. + var s = state + s.phase = .streaming + s.uptimeMs = b.uptimeMs + for (k, v) in b.fields { s.fields[k] = v } + s.truncated = truncated + if let sources = b.sources { s.sources = sources } + if let blocked = b.dispatchBlockedBy { s.dispatchBlockedBy = blocked } + state = s + } + + private func onAPIHead(_ id: UInt32?, _ body: CBOR) { + guard let id, var p = pendingAPI[id] else { return } + // A second status for one request: the first is the one committed to. + if p.head != nil { return } + var headers = [String: String]() + for e in body["headers"]?.entries ?? [] { + if let k = e.key.string, let v = e.value.string { headers[k.lowercased()] = v } + } + p.head = (body["status"]?.int ?? 0, headers) + pendingAPI[id] = p + armAPI(id) + } + + private func onAPIChunk(_ id: UInt32?, _ body: CBOR) { + guard let id, var p = pendingAPI[id] else { return } + // A body assembled out of order is bytes that were never sent, + // presented as the box's. Fail the request instead. + guard body["seq"]?.int == p.nextSeq else { + pendingAPI[id] = nil + p.timer?.cancel() + p.continuation.resume(throwing: SessionError.outOfOrder) + return + } + p.nextSeq += 1 + p.chunks += body["data"]?.byteString ?? [] + pendingAPI[id] = p + armAPI(id) + } + + private func onAPIEnd(_ id: UInt32?, _ body: CBOR) { + guard let id, let p = pendingAPI.removeValue(forKey: id) else { return } + p.timer?.cancel() + // Half a document is wrong in a way no caller above can see. + if body["truncated"]?.bool == true { + p.continuation.resume(throwing: BoxRefusal(detail: ErrorMsg(code: "E_RESPONSE_TOO_LARGE", retryable: false, args: ["bytes": body["bytes"] ?? .null]))) + return + } + guard let head = p.head else { + p.continuation.resume(throwing: SessionError.noStatus) + return + } + p.continuation.resume(returning: APIResponse(status: head.status, headers: head.headers, body: p.chunks)) + } + + /// An error carrying a request id belongs to that request alone, so one + /// window the box could not serve never makes the whole app look broken. + private func onError(_ b: ErrorMsg, id: UInt32?) { + if let id { + if let p = pendingHistory.removeValue(forKey: id) { + p.timer?.cancel() + p.continuation.resume(throwing: BoxRefusal(detail: b)) + return + } + if let p = pendingPlan.removeValue(forKey: id) { + p.timer?.cancel() + p.continuation.resume(throwing: BoxRefusal(detail: b)) + return + } + if let p = pendingPrices.removeValue(forKey: id) { + p.timer?.cancel() + p.continuation.resume(throwing: BoxRefusal(detail: b)) + return + } + if let p = pendingAPI.removeValue(forKey: id) { + p.timer?.cancel() + p.continuation.resume(throwing: BoxRefusal(detail: b)) + return + } + } + var s = state + s.lastError = b + state = s + } + + // MARK: Frames out + + private func sendHello() { + // Any hello supersedes a retry waiting to send one. + bootRetry?.cancel() + helloSub = subscription + send(Envelope(t: "hello", b: .map([ + ("proto", .map([("min", .int(Proto.min)), ("max", .int(Proto.max))])), + ("app", .map([("build", .text(build)), ("ua", .text(ua))])), + ("locales", .array(locales.map { .text($0) })), + ("sub", subscription.cbor), + ]))) + } + + private func sendSub() { + send(Envelope(t: "sub", b: subscription.cbor)) + } + + private func send(_ envelope: Envelope) { + guard let carrier, let frame = try? Frame.encode(lane: Frame.laneControl, envelope: envelope, bucket: subscription.bucket) else { return } + carrier.send(frame) + } + + // MARK: Deadlines + + /// The deadline measures silence, not total time: every chunk re-arms it. + private func armHistory(_ id: UInt32) { + guard var p = pendingHistory[id] else { return } + p.timer?.cancel() + p.timer = scheduler.after(Self.historyTimeoutMs) { [weak self] in + guard let p = self?.pendingHistory.removeValue(forKey: id) else { return } + p.continuation.resume(throwing: SessionError.timedOut("history")) + } + pendingHistory[id] = p + } + + private func armAPI(_ id: UInt32) { + guard var p = pendingAPI[id] else { return } + p.timer?.cancel() + p.timer = scheduler.after(Self.apiTimeoutMs) { [weak self] in + guard let p = self?.pendingAPI.removeValue(forKey: id) else { return } + p.continuation.resume(throwing: SessionError.timedOut("api")) + } + pendingAPI[id] = p + } + + private func detach() { + bootRetry?.cancel() + if let carrier { + carrier.setHandlers(onFrame: { _ in }, onStatus: { _ in }) + carrier.close(reason: "closed by client") + } + carrier = nil + settlePending() + } + + /// End everything waiting on a carrier that will not answer. A command + /// is settled the way its own deadlines would settle it: never acked + /// means it did not reach the box; acked means it may well have run. + private func settlePending() { + apiGeneration += 1 + for (_, p) in pendingHistory { p.timer?.cancel(); p.continuation.resume(throwing: SessionError.carrierClosed) } + pendingHistory = [:] + for (_, p) in pendingPlan { p.timer?.cancel(); p.continuation.resume(throwing: SessionError.carrierClosed) } + pendingPlan = [:] + for (_, p) in pendingPrices { p.timer?.cancel(); p.continuation.resume(throwing: SessionError.carrierClosed) } + pendingPrices = [:] + for (_, p) in pendingAPI { p.timer?.cancel(); p.continuation.resume(throwing: SessionError.carrierClosed) } + pendingAPI = [:] + let commands = pendingCommands + pendingCommands = [:] + for (cmdId, p) in commands { + p.ackTimer.cancel() + p.confirmTimer.cancel() + if p.acked { + p.continuation.resume(returning: CmdResult(cmdId: cmdId, state: .unconfirmed)) + } else { + p.continuation.resume(throwing: CommandError(code: "E_NO_ACK", help: "That didn't reach your box. Try again.")) + } + } + } +} + +/// What the app keeps of a house between launches: enough to paint the +/// first frame honestly, with its age. +public struct CachedSnapshot: Codable, Equatable, Sendable { + public var siteId: String + /// Wall clock when it was written. + public var savedAtMs: Double + public var uptimeMs: Double + public var controlRev: UInt64 + public var fields: [Int: Double] + public var sources: [String: Source] + public var dispatchBlockedBy: [String] + public var dict: [Int: FieldDef] + + public init(siteId: String, savedAtMs: Double, state: SessionState) { + self.siteId = siteId + self.savedAtMs = savedAtMs + uptimeMs = state.uptimeMs + controlRev = state.controlRev + fields = state.fields + sources = state.sources + dispatchBlockedBy = state.dispatchBlockedBy + dict = state.dict + } + + func apply(to s: inout SessionState, includeClock: Bool) { + if includeClock { s.uptimeMs = uptimeMs } + s.controlRev = controlRev + s.fields = fields + s.sources = sources + s.dispatchBlockedBy = dispatchBlockedBy + s.dict = dict + } +} diff --git a/appleApp/FTWKit/Sources/FTWKit/State/AppModel.swift b/appleApp/FTWKit/Sources/FTWKit/State/AppModel.swift new file mode 100644 index 0000000..59ff3f6 --- /dev/null +++ b/appleApp/FTWKit/Sources/FTWKit/State/AppModel.swift @@ -0,0 +1,270 @@ +import Foundation +import Observation + +/// Every model one home's screens read, built once per home and kept while +/// tabs switch, so a second visit is instant and keeps its state. +@MainActor +public final class HomeModels { + public let site: SiteModel + public let plan: PlanModel + public let prices: PriceModel + public let status: StatusWatch + public let charging: ChargingWatch + public let savings: SavingsModel + public let energy: EnergyModel + public let history: HistoryModel + public let loadpoints: LoadpointsModel + public let access: AccessModel + public let notify: NotifyModel + public let restart: RestartModel + + init(site: SiteModel, files: SealedFiles?, thisPhone: String?) { + self.site = site + plan = PlanModel(site: site) + prices = PriceModel(site: site) + status = StatusWatch(site: site) + charging = ChargingWatch(site: site) + savings = SavingsModel(site: site) + energy = EnergyModel(site: site) + history = HistoryModel(site: site, tiles: TileCache(files: site.isDemo ? nil : files, siteId: site.siteId ?? "demo")) + loadpoints = LoadpointsModel(site: site, charging: charging) + access = AccessModel(site: site, thisPhone: thisPhone) + notify = NotifyModel(site: site) + restart = RestartModel(site: site) + } +} + +/// No carrier could be built from what this phone holds. Every other +/// failure heals itself because the carrier reconnects from inside; these +/// are the ones with nothing to reconnect, so the screen offers a way back. +public struct ConnectError: Error, Equatable, HelpfulError { + public enum Kind: Sendable { case notPaired, notEnrolled, locked, stalePairing } + public let kind: Kind + public let help: String +} + +/// The shell: which home is open, how it is reached, pairing, the demo and +/// leaving. Paints before any data arrives and never blocks on the network. +@Observable +@MainActor +public final class AppModel { + public private(set) var home: HomeModels? + /// What to do when no carrier could be built. Nil while one exists. + public private(set) var connectHelp: String? + /// The pairing screen, opened from a home that has lost its way in. + public var recovering = false + /// The last sign-out could not clear the disk; the home was put back. + public private(set) var leaveFailed = false + /// A pairing link that arrived from outside, shown before it is trusted. + public var offeredLink: String? + public private(set) var isDemo = false + /// The Box screen's spare-key switch for the open home. Nil in the demo. + public private(set) var sealedCopy: SealedCopyModel? + + @ObservationIgnored public let vault: Vault + @ObservationIgnored public let sites: SiteList + @ObservationIgnored public let escrow: Escrow + @ObservationIgnored public let pairing: Pairing + @ObservationIgnored public let passkeys: PasskeyAuthenticator? + @ObservationIgnored let store: SecureStore + @ObservationIgnored let files: SealedFiles? + @ObservationIgnored let scheduler: Scheduler + @ObservationIgnored let build: String + @ObservationIgnored let ua: String + @ObservationIgnored let relayURL: URL + @ObservationIgnored let makeSocket: WebSocketFactory + @ObservationIgnored private var demoBox: SimulatedBox? + @ObservationIgnored private var connectGeneration = 0 + + public init( + store: SecureStore, + files: SealedFiles?, + passkeys: PasskeyAuthenticator?, + scheduler: Scheduler = LiveScheduler.shared, + build: String, + ua: String, + relayURL: URL = Origin.relayURL, + escrowTransport: Escrow.Transport? = nil, + makeSocket: @escaping WebSocketFactory = URLSessionWebSocket.factory() + ) { + self.store = store + self.files = files + self.passkeys = passkeys + self.scheduler = scheduler + self.build = build + self.ua = ua + self.relayURL = relayURL + self.makeSocket = makeSocket + vault = Vault(store: store) + sites = SiteList(store: store) + escrow = Escrow(vault: vault, sites: sites, transport: escrowTransport) + pairing = Pairing(vault: vault, sites: sites, escrow: escrow, passkeys: passkeys, now: { [scheduler] in scheduler.nowMs }) + } + + public var site: SiteModel? { home?.site } + + /// Nothing paired and nothing to show: the only screen is pairing. + public var needsPairing: Bool { home == nil || recovering || offeredLink != nil } + + /// The launch path: a paired phone paints its cached home at once and + /// connects behind it. + public func launch() { + guard home == nil, let current = sites.current() else { return } + open(current.siteId) + } + + /// Point the app at a home and connect to it. + public func open(_ siteId: String) { + exitDemo() + home?.site.destroy() + let site = SiteModel(siteId: siteId, build: build, ua: ua, scheduler: scheduler, files: files) + site.stepUp = { [weak self] in await self?.stepUp() ?? .unavailable } + home = HomeModels(site: site, files: files, thisPhone: vault.deviceIDOnBox) + sealedCopy = SealedCopyModel(app: self, siteId: siteId) + sites.setCurrent(siteId) + site.start() + recovering = false + Task { await connect() } + } + + func stepUp() async -> StepUpOutcome { + await vault.stepUp(passkeys) + } + + /// Build the carrier stack silently. Reading your own house is not a + /// privilege; a device enrolled before the local copy pays one last + /// prompt, and never again. + public func connect() async { + guard let site = home?.site, let siteId = site.siteId else { return } + connectGeneration += 1 + let mine = connectGeneration + do { + guard let stored = sites.get(siteId) else { + throw ConnectError(kind: .notPaired, help: "This phone has no record of that home.") + } + guard vault.isEnrolled else { + throw ConnectError(kind: .notEnrolled, help: "This device has no key for that home.") + } + // Read from the QR, never derived: a handle derived from the box + // key would be one household identifier good for years. + guard let secret = stored.rendezvousSecret?.byteArray else { + throw ConnectError(kind: .stalePairing, help: "This home was paired before this app could reach it privately.") + } + var wrapping = try vault.silentWrappingKey() + if wrapping == nil { + let prompted = try await vault.unlockWrappingKey(passkeys) + try vault.ensureLocalCopy(prompted) + wrapping = prompted + } + let device = try vault.deviceKey(wrapping!) + guard mine == connectGeneration, home?.site === site else { return } + + let box = stored.boxStaticKey.byteArray + let relay = RelayCarrier(url: relayURL, secret: secret, scheduler: scheduler, makeSocket: makeSocket) + let noise = NoiseCarrier( + inner: relay, + staticKey: device, + remoteStatic: box, + prologue: NoiseCarrier.prologue(boxStaticKey: box), + handshakePayload: stored.pairingCode?.byteArray ?? [], + scheduler: scheduler + ) + if site.connect(noise) { connectHelp = nil } + } catch let e as HelpfulError { + guard mine == connectGeneration else { return } + connectHelp = e.help + } catch { + guard mine == connectGeneration else { return } + connectHelp = "This device can no longer unlock its key. Open your box's local dashboard, then Settings → FTW app → Show pairing code, and scan a new QR." + } + } + + /// Pull to refresh: a fresh stream in place, or a new carrier if there + /// was none. + public func refresh() async { + if isDemo { + demoBox?.tick() + return + } + if connectHelp != nil || site?.core.hasCarrier == false { + await connect() + } else { + site?.refresh() + } + } + + public func setVisible(_ visible: Bool) { + site?.setVisible(visible) + } + + public func networkOnline() { + site?.networkOnline() + } + + /// A pairing link arrived. It is an offer, never an instruction: anyone + /// can send a link, so it is shown with the box it names first. A link + /// to the box this phone already has is a leftover and is dropped. + public func offer(_ link: String) { + guard let enrollment = try? Enrollment.parse(scanned: link) else { + offeredLink = link + return + } + if let current = site?.siteId, SiteList.siteID(enrollment.boxStaticPublic) == current, sites.get(current) != nil { + return + } + offeredLink = link + } + + public func paired(_ siteId: String) { + offeredLink = nil + recovering = false + open(siteId) + } + + // MARK: The demo + + /// A simulated home in this process. Nothing is written and no passkey + /// is asked for; leaving it touches no saved home. + public func startDemo() { + home?.site.destroy() + let site = SiteModel(siteId: nil, build: build, ua: ua, scheduler: scheduler, files: nil, isDemo: true) + site.ceilingW = 11_000 + let box = SimulatedBox(scheduler: scheduler) + demoBox = box + home = HomeModels(site: site, files: nil, thisPhone: "Qm94T3du") + sealedCopy = nil + isDemo = true + recovering = false + offeredLink = nil + connectHelp = nil + site.start() + site.connect(LoopbackCarrier(box: box, scheduler: scheduler)) + } + + public func exitDemo() { + guard isDemo else { return } + home?.site.destroy() + home = nil + sealedCopy = nil + demoBox = nil + isDemo = false + if let current = sites.current() { open(current.siteId) } + } + + // MARK: Leaving + + /// Sign out on this phone: the stream stops first and the disk is + /// cleared second, so a reading landing mid-clear cannot write the home + /// back. The sealed copy at Sourceful stays; removing it is its own act. + public func leave() { + home?.site.destroy() + home = nil + sealedCopy = nil + vault.reset() + sites.clear() + files?.clear() + connectHelp = nil + leaveFailed = false + recovering = false + } +} diff --git a/appleApp/FTWKit/Sources/FTWKit/State/BoxAPI.swift b/appleApp/FTWKit/Sources/FTWKit/State/BoxAPI.swift new file mode 100644 index 0000000..2b34642 --- /dev/null +++ b/appleApp/FTWKit/Sources/FTWKit/State/BoxAPI.swift @@ -0,0 +1,158 @@ +import Foundation + +/// Something a screen can put in front of a person. `code` is kept for the +/// few callers that branch on it; no caller renders it. +public struct BoxAPIError: Error, Equatable, HelpfulError { + public let code: String + public let help: String + /// The HTTP status when a handler answered. Nil when none ran. + public let status: Int? +} + +extension SiteModel { + /// Call the box's own API and get JSON back, with sentences. + /// + /// It runs the step-up: a write refused with E_NEEDS_STEP_UP is asked + /// again once, after the passkey ceremony, and never with the flag + /// before one has run. The box prices each route; this app carries no + /// list of them. + public func callBox(_ method: APIMethod, _ path: String, query: [String: String] = [:], body: JSON? = nil) async throws -> JSON? { + let res = try await sendBox(method, path, query: query, body: body, stepUp: false) + guard (200..<300).contains(res.status) else { + let code = Self.codeFromBody(res.body) + throw BoxAPIError(code: code ?? "HTTP_\(res.status)", help: Self.statusHelp(res.status, code), status: res.status) + } + if res.body.isEmpty { return nil } + do { + return try JSON(parsing: res.body) + } catch { + throw BoxAPIError(code: "E_BAD_BODY", help: "Your box sent something this app couldn't read.", status: res.status) + } + } + + private func sendBox(_ method: APIMethod, _ path: String, query: [String: String], body: JSON?, stepUp: Bool) async throws -> APIResponse { + do { + return try await api(APIRequest(method: method, path: path, query: query, body: body?.encoded(), stepUp: stepUp)) + } catch let refusal as BoxRefusal { + let code = refusal.detail.code + // The one place a step-up runs, and only once. A second refusal + // after a ceremony is the box saying something else. + if code == "E_NEEDS_STEP_UP", !stepUp { + let outcome = await self.stepUp?() ?? .unavailable + guard outcome == .done else { + throw BoxAPIError(code: code, help: outcome.help ?? "", status: nil) + } + return try await sendBox(method, path, query: query, body: body, stepUp: true) + } + throw BoxAPIError(code: code, help: Self.refusalHelp(code, refusal.detail.args), status: nil) + } catch { + // No code and no status: the wire went away or the deadline + // passed. Both heal on their own. + throw BoxAPIError(code: "E_NO_ANSWER", help: "Your box didn't answer. Still trying.", status: nil) + } + } + + static func codeFromBody(_ body: Bytes) -> String? { + guard !body.isEmpty, let code = (try? JSON(parsing: body))?["code"]?.string, code.hasPrefix("E_") else { return nil } + return code + } + + /// What happens now, for a refusal that never reached a handler. + public static func refusalHelp(_ code: String, _ args: [String: CBOR] = [:]) -> String { + switch code { + case "E_UNKNOWN_OP": return "Your box doesn't have that yet — it may be running older software." + case "E_UNAVAILABLE": + return args["reason"]?.string == "busy" ? "Your box is busy with something else. This will fill in shortly." : "Your box can't answer that right now. Still trying." + case "E_SCOPE_DENIED": return "Only the owner of this home can change that." + case "E_GRANT_REVOKED": return "Your access to this home was withdrawn by its owner." + case "E_USE_CMD": return "That has to be done from the controls on the home screen." + case "E_UNSUPPORTED_MEDIA", "E_WHOLE_DOCUMENT", "E_LOCAL_ONLY": return "That one is only available on your box's own page, from home." + case "E_RESPONSE_TOO_LARGE": return "That's more than we can send over your connection — narrow the range." + case "E_NEEDS_STEP_UP": return "Your box needs more proof than this phone can give. Do it on your box." + default: return "That didn't work. Nothing on your box has changed." + } + } + + /// What happens now, for a status a handler answered with. + public static func statusHelp(_ status: Int, _ code: String?) -> String { + if code == "E_LAST_OWNER_PROTECTED" { return "Someone has to own this home, so the last owner cannot be removed." } + switch status { + case 404: return "That's no longer on your box." + case 403: return "Your box refused that from this phone." + case 409: return "Something changed on your box first. Nothing here was applied." + case 503: return "Your box can't answer that yet. Still trying." + case 500...: return "Something went wrong on your box. Nothing has changed." + default: return "Your box couldn't do that." + } + } +} + +/// One rule for everything a screen asks the box for: ask while the session +/// streams, again every time it comes back, and again after a failure, with +/// a wait that doubles up to a quarter of an hour. A carrier that drops +/// settles every request at once, and nothing else would ever ask again. +@MainActor +public final class LiveAsk { + static let retryMs: Double = 30_000 + static let ceilingMs: Double = 15 * 60_000 + + private unowned let site: SiteModel + private let want: @MainActor () -> String? + private let ask: @MainActor () async throws -> Void + private var asked: String? + private var tries = 0 + private var waitMs = LiveAsk.retryMs + private var timer: Cancellable? + private var generation = 0 + private var token: Int? + + /// `want` names what to ask for; the same name is not asked twice, and + /// nil means there is nothing to ask. `ask` rejects to be asked again. + public init(site: SiteModel, want: @escaping @MainActor () -> String?, ask: @escaping @MainActor () async throws -> Void) { + self.site = site + self.want = want + self.ask = ask + token = site.observe { [weak self] in self?.evaluate() } + } + + public func stop() { + generation += 1 + timer?.cancel() + if let token { site.unobserve(token) } + token = nil + } + + /// Check whether to ask now. Called on every session change and tick, + /// and by the owner when what it wants changes. + public func evaluate() { + guard token != nil else { return } + guard site.session.phase == .streaming, let name = want() else { + // Whatever was asked is unanswered now; the next live moment asks + // again rather than waiting out a backoff. + asked = nil + waitMs = Self.retryMs + timer?.cancel() + return + } + let key = "\(name) \(tries)" + if asked == key { return } + asked = key + timer?.cancel() + generation += 1 + let mine = generation + Task { @MainActor [weak self] in + guard let self else { return } + do { + try await self.ask() + if mine == self.generation { self.waitMs = Self.retryMs } + } catch { + guard mine == self.generation else { return } + self.timer = self.site.scheduler.after(self.waitMs) { [weak self] in + self?.tries += 1 + self?.evaluate() + } + self.waitMs = min(self.waitMs * 2, Self.ceilingMs) + } + } + } +} diff --git a/appleApp/FTWKit/Sources/FTWKit/State/BoxModels.swift b/appleApp/FTWKit/Sources/FTWKit/State/BoxModels.swift new file mode 100644 index 0000000..7477c8c --- /dev/null +++ b/appleApp/FTWKit/Sources/FTWKit/State/BoxModels.swift @@ -0,0 +1,335 @@ +import Foundation +import Observation + +/// Who can see this home, through the box's own roster. Reading is a GET; +/// inviting and removing are writes, so the box asks for a ceremony first. +/// +/// An invite is the ordinary pairing code minted with the viewer role and a +/// shorter life. The role is never in the payload: a role its holder could +/// edit is not a role. +@Observable +@MainActor +public final class AccessModel: Activatable { + public struct Member: Equatable, Sendable, Identifiable { + /// The box's name for the row: the first eight characters of its key. + public let id: String + public let role: String + public let addedAtMs: Double? + public let lastSeenMs: Double? + public let isThisPhone: Bool + } + + public struct Invite: Equatable, Sendable { + /// The whole QR payload. It may only leave as a square for a camera. + public let url: String + public let role: String + public let expiresAtMs: Double + } + + public enum Busy: Sendable { case none, inviting, revoking } + + public private(set) var members: [Member] = [] + public private(set) var loading = false + public private(set) var loaded = false + public private(set) var error: String? + public private(set) var invite: Invite? + public private(set) var busy: Busy = .none + + @ObservationIgnored private unowned let site: SiteModel + @ObservationIgnored private let thisPhone: String? + @ObservationIgnored private var ask: LiveAsk? + @ObservationIgnored private var active = 0 + @ObservationIgnored private var token = 0 + + public init(site: SiteModel, thisPhone: String?) { + self.site = site + self.thisPhone = thisPhone + } + + public var canManage: Bool { site.canConfigure && site.hasPassthrough } + public var owners: Int { members.filter { $0.role == Contract.roleOwner }.count } + + public func activate() { + active += 1 + if ask == nil { + ask = LiveAsk(site: site, want: { [weak self] in + guard let self, self.active > 0, self.canManage else { return nil } + return "members" + }, ask: { [weak self] in try await self?.load() }) + } + ask?.evaluate() + } + + public func deactivate() { + active = max(0, active - 1) + ask?.evaluate() + } + + func load() async throws { + token += 1 + let mine = token + loading = true + error = nil + defer { if mine == token { loading = false } } + do { + let wire = try await site.callBox(.get, "/api/app-link/devices") + guard mine == token else { return } + members = (wire?["devices"]?.array ?? []).map { d in + let id = d["id"]?.string ?? "" + // A row from a box before roles is an owner. + let role = d["role"]?.string.flatMap { $0.isEmpty ? nil : $0 } ?? Contract.roleOwner + return Member(id: id, role: role, addedAtMs: d["added_at_ms"]?.number, lastSeenMs: d["last_seen_ms"]?.number, isThisPhone: id == thisPhone) + } + loaded = true + } catch { + guard mine == token else { return } + self.error = (error as? BoxAPIError)?.help ?? "Your box didn't answer. Still trying." + throw error + } + } + + /// Mint a code that admits a viewer. The role goes in the body, where the + /// box reads it, and an answer naming any other role is refused here. + @discardableResult + public func inviteViewer() async -> Bool { + busy = .inviting + error = nil + defer { busy = .none } + do { + let wire = try await site.callBox(.post, "/api/app-link/pairing", body: ["role": .string(Contract.roleViewer)]) + guard wire?["role"]?.string == Contract.roleViewer, let url = wire?["url"]?.string else { + throw BoxAPIError(code: "E_BAD_BODY", help: "Your box didn't make a view-only invitation. Nothing has been shared.", status: nil) + } + invite = Invite(url: url, role: Contract.roleViewer, expiresAtMs: wire?["expires_at_ms"]?.number ?? 0) + return true + } catch { + self.error = (error as? BoxAPIError)?.help ?? "That did not work. Nothing has changed." + return false + } + } + + public func dismissInvite() { invite = nil } + + /// Withdraw a phone's access. The row goes on the box's answer alone. + @discardableResult + public func revoke(_ id: String) async -> Bool { + busy = .revoking + error = nil + defer { busy = .none } + do { + _ = try await site.callBox(.delete, "/api/app-link/devices/\(LoadpointsModel.escape(id))") + members.removeAll { $0.id == id } + return true + } catch let e as BoxAPIError where e.status == 404 { + // Already gone is what was asked for. + members.removeAll { $0.id == id } + return true + } catch { + self.error = (error as? BoxAPIError)?.help ?? "That did not work. Nothing has changed." + return false + } + } + + public static func roleLabel(_ role: String) -> String { + Contract.roleLabels[role] ?? role + } + + public func seen(_ ms: Double?) -> String { + guard let ms, ms > 0 else { return "not seen yet" } + let since = site.nowMs - ms + if since < 120_000 { return "here now" } + if since < 3_600_000 { return "\(Int((since / 60_000).rounded())) min ago" } + if since < 86_400_000 { return "\(Int((since / 3_600_000).rounded())) h ago" } + let f = DateFormatter() + f.setLocalizedDateFormatFromTemplate("dMMM") + return f.string(from: Date(timeIntervalSince1970: ms / 1000)) + } +} + +/// What the box sends when something at home matters, and what it has sent. +/// +/// The rules are the box's document and govern every phone it can reach. +/// Each entry goes out whole on a save, flipped only on `enabled`, because +/// the box replaces a rule wholesale and a partial one would wipe the +/// thresholds it seeded. +@Observable +@MainActor +public final class NotifyModel: Activatable { + /// The kinds the rules govern. box.unreachable is not one: the box cannot + /// gate a message about its own absence. + public static let ruleKinds = [ + "charging.connected", "charging.session_complete", "charging.interrupted", + "update.installed", "driver.offline", "fuse.over_limit", + ] + + public static let labels: [String: String] = [ + "charging.connected": "When the car is plugged in", + "charging.session_complete": "When the car finishes charging", + "charging.interrupted": "If charging stops before it is done", + "update.installed": "When your box updates itself", + "driver.offline": "If a device goes quiet", + "fuse.over_limit": "If the house draws more than the fuse allows", + "box.unreachable": "If your box goes out of reach", + ] + + public struct Sent: Equatable, Sendable, Identifiable { + public let title: String + public let atMs: Double? + public var id: String { "\(title)\(atMs ?? 0)" } + } + + public enum Busy: Sendable { case none, saving, testing } + + public private(set) var boxEnabled = false + public private(set) var rules: [String: Bool] = [:] + public private(set) var availableKinds: [String] = [] + public private(set) var history: [Sent] = [] + public private(set) var oldBox = false + public private(set) var busy: Busy = .none + public private(set) var error: String? + public private(set) var testSent = false + + @ObservationIgnored private unowned let site: SiteModel + @ObservationIgnored private var doc: [JSON] = [] + @ObservationIgnored private var ask: LiveAsk? + @ObservationIgnored private var active = 0 + + public init(site: SiteModel) { + self.site = site + } + + public var canManage: Bool { site.canConfigure && site.hasPassthrough } + + public func activate() { + active += 1 + if ask == nil { + ask = LiveAsk(site: site, want: { [weak self] in + guard let self, self.active > 0, self.canManage, !self.oldBox else { return nil } + return "push-history" + }, ask: { [weak self] in + try await self?.loadHistory() + try await self?.loadRules() + }) + } + ask?.evaluate() + } + + public func deactivate() { + active = max(0, active - 1) + ask?.evaluate() + } + + private func apply(_ wire: JSON?) { + guard let events = wire?["events"]?.array else { return } + doc = events.filter { $0["type"]?.string != nil && $0["enabled"]?.bool != nil } + boxEnabled = wire?["enabled"]?.bool == true + availableKinds = doc.compactMap { $0["type"]?.string } + var next = [String: Bool]() + for k in Self.ruleKinds { next[k] = false } + for r in doc { if let t = r["type"]?.string, next[t] != nil { next[t] = r["enabled"]?.bool == true } } + rules = next + } + + func loadRules() async throws { + do { + apply(try await site.callBox(.get, "/api/notifications/rules")) + } catch let e as BoxAPIError where e.code == "E_UNKNOWN_OP" { + oldBox = true + } + } + + func loadHistory() async throws { + do { + let wire = try await site.callBox(.get, "/api/notifications/history") + history = (wire?["events"]?.array ?? []).map { Sent(title: $0["title"]?.string ?? "", atMs: $0["at_ms"]?.number) } + } catch let e as BoxAPIError where e.code == "E_UNKNOWN_OP" { + oldBox = true + } + } + + /// One PUT for the whole set, so one ceremony. Kinds the box's document + /// does not carry are not sent: an older box must not meet a kind it + /// would refuse by name. + @discardableResult + public func save(_ next: [String: Bool]) async -> Bool { + guard busy == .none else { return false } + busy = .saving + error = nil + testSent = false + defer { busy = .none } + do { + if doc.isEmpty { try await loadRules() } + guard !doc.isEmpty else { + error = "Your box didn't answer. Nothing has changed." + return false + } + let events: [JSON] = doc.compactMap { rule in + guard let t = rule["type"]?.string, Self.ruleKinds.contains(t), let on = next[t] else { return nil } + return rule.setting("enabled", .bool(on)) + } + apply(try await site.callBox(.put, "/api/notifications/rules", body: ["enabled": true, "events": .array(events)])) + return true + } catch { + fail(error) + return false + } + } + + /// One real push through the whole pipe, to the phones the box can reach. + public func sendTest() async { + guard busy == .none else { return } + busy = .testing + error = nil + testSent = false + defer { busy = .none } + do { + _ = try await site.callBox(.post, "/api/notifications/test") + testSent = true + } catch { + fail(error) + } + } + + private func fail(_ err: Error) { + if let e = err as? BoxAPIError, e.code == "E_UNKNOWN_OP" { oldBox = true } + error = (err as? BoxAPIError)?.help ?? "That didn't work. Nothing has changed." + } + + public func when(_ ms: Double?) -> String { + guard let ms, ms > 0 else { return "" } + let since = site.nowMs - ms + if since < 3_600_000 { return "\(max(1, Int((since / 60_000).rounded()))) min ago" } + if since < 86_400_000 { return "\(Int((since / 3_600_000).rounded())) h ago" } + let f = DateFormatter() + f.setLocalizedDateFormatFromTemplate("dMMM") + return f.string(from: Date(timeIntervalSince1970: ms / 1000)) + } +} + +/// Ask the box to come back up: a recovery act, owner only, confirmed. +@Observable +@MainActor +public final class RestartModel { + public enum Stage: Sendable { case idle, confirming, restarting } + public var stage: Stage = .idle + public private(set) var error: String? + @ObservationIgnored private unowned let site: SiteModel + + public init(site: SiteModel) { + self.site = site + } + + public var canAsk: Bool { site.heardFromBox && site.canConfigure && site.hasPassthrough } + + public func restart() async { + guard stage != .restarting else { return } + error = nil + stage = .restarting + do { + _ = try await site.callBox(.post, "/api/restart") + } catch { + stage = .idle + self.error = (error as? BoxAPIError)?.help ?? "That didn't work. Nothing has changed." + } + } +} diff --git a/appleApp/FTWKit/Sources/FTWKit/State/EnergyHistory.swift b/appleApp/FTWKit/Sources/FTWKit/State/EnergyHistory.swift new file mode 100644 index 0000000..2f055a5 --- /dev/null +++ b/appleApp/FTWKit/Sources/FTWKit/State/EnergyHistory.swift @@ -0,0 +1,288 @@ +import Foundation +import Observation + +/// What the house used, made, bought and sold, day by day, from +/// GET /api/energy/daily. Integer watt-hours throughout, rounded once at the +/// door, so the total over the chart is the sum of the bars under it. +@Observable +@MainActor +public final class EnergyModel: Activatable { + public enum Range: String, CaseIterable, Sendable, Identifiable { + case today, week = "7d", month = "30d" + public var id: String { rawValue } + public var label: String { self == .today ? "Today" : self == .week ? "7 days" : "30 days" } + public var title: String { self == .today ? "Today" : self == .week ? "Last 7 days" : "Last 30 days" } + public var days: Int { self == .today ? 1 : self == .week ? 7 : 30 } + } + + public struct Day: Equatable, Sendable, Identifiable { + public let day: String + public let loadWh: Double + public let pvWh: Double + public let importWh: Double + public let exportWh: Double + public let batChargedWh: Double + public let batDischargedWh: Double + public var id: String { day } + } + + public struct Totals: Equatable, Sendable { + public var loadWh: Double = 0 + public var pvWh: Double = 0 + public var importWh: Double = 0 + public var exportWh: Double = 0 + public var batChargedWh: Double = 0 + public var batDischargedWh: Double = 0 + } + + public private(set) var range: Range = .week + /// Oldest first, today last. + public private(set) var days: [Day] = [] + public private(set) var loading = false + /// Whether the box has answered for this period. A period not yet read + /// is not a period with nothing in it. + public private(set) var loaded = false + public private(set) var error: String? + + @ObservationIgnored private unowned let site: SiteModel + @ObservationIgnored private var ask: LiveAsk? + @ObservationIgnored private var active = 0 + @ObservationIgnored private var token = 0 + @ObservationIgnored private let calendar: Calendar + + public init(site: SiteModel, calendar: Calendar = .current) { + self.site = site + self.calendar = calendar + } + + public var totals: Totals { + days.reduce(into: Totals()) { t, d in + t.loadWh += d.loadWh; t.pvWh += d.pvWh; t.importWh += d.importWh + t.exportWh += d.exportWh; t.batChargedWh += d.batChargedWh; t.batDischargedWh += d.batDischargedWh + } + } + + /// "As much as", never "of": the sun and the kettle rarely coincide. + public var solarSharePct: Int? { + let t = totals + guard t.loadWh > 0, t.pvWh > 0 else { return nil } + return Int((t.pvWh / t.loadWh * 100).rounded()) + } + + public func activate() { + active += 1 + if ask == nil { + ask = LiveAsk(site: site, want: { [weak self] in + guard let self, self.active > 0, self.site.documentVisible else { return nil } + guard self.site.hasPassthrough else { + if !self.days.isEmpty { self.days = [] } + return nil + } + // Today's column fills all day, so the hour is in the name. + let now = Date(timeIntervalSince1970: self.site.nowMs / 1000) + return "energy \(self.range.rawValue) \(self.calendar.startOfDay(for: now).timeIntervalSince1970) \(self.calendar.component(.hour, from: now))" + }, ask: { [weak self] in try await self?.load() }) + } + ask?.evaluate() + } + + public func deactivate() { + active = max(0, active - 1) + ask?.evaluate() + } + + public func select(_ next: Range) { + guard next != range else { return } + token += 1 + range = next + days = [] + loaded = false + loading = false + error = nil + ask?.evaluate() + } + + func load() async throws { + token += 1 + let mine = token + loading = true + error = nil + defer { if mine == token { loading = false } } + do { + let wire = try await site.callBox(.get, "/api/energy/daily", query: ["days": String(range.days)]) + guard mine == token else { return } + days = (wire?["days"]?.array ?? []).map { row in + Day( + day: row["day"]?.string ?? "", + loadWh: EnergyFormat.wholeWh(row["load_wh"]?.number), + pvWh: EnergyFormat.wholeWh(row["pv_wh"]?.number), + importWh: EnergyFormat.wholeWh(row["import_wh"]?.number), + exportWh: EnergyFormat.wholeWh(row["export_wh"]?.number), + batChargedWh: EnergyFormat.wholeWh(row["bat_charged_wh"]?.number), + batDischargedWh: EnergyFormat.wholeWh(row["bat_discharged_wh"]?.number) + ) + } + loaded = true + } catch { + guard mine == token else { return } + if let e = error as? BoxAPIError, days.isEmpty { + self.error = e.help + } else { + self.error = "Not up to date — your box is out of reach" + } + throw error + } + } +} + +/// Power over time: cache first, box second, then the difference only. +@Observable +@MainActor +public final class HistoryModel: Activatable { + public enum Range: String, CaseIterable, Sendable, Identifiable { + case day = "24h", week = "7d", month = "30d", year = "1y" + public var id: String { rawValue } + public var label: String { + switch self { + case .day: return "24 h" + case .week: return "7 d" + case .month: return "30 d" + case .year: return "1 y" + } + } + var spanMs: Double { + switch self { + case .day: return 86_400_000 + case .week: return 7 * 86_400_000 + case .month: return 30 * 86_400_000 + case .year: return 365 * 86_400_000 + } + } + var res: Resolution { self == .year ? .hour : .fiveMinutes } + } + + /// Names from the registry, in the order they are drawn. + public static let series = ["grid_w", "pv_w", "battery_w", "load_w"] + static let maxPoints = 1500 + + public private(set) var range: Range = .day + public private(set) var frame: HistoryGeometry.Frame? + public private(set) var resActual: Resolution? + public private(set) var gaps: [HistGap] = [] + public private(set) var loading = false + public private(set) var loaded = false + public private(set) var error: String? + /// The sample under the finger, as an index into `frame`. + public var cursor: Int? + + @ObservationIgnored private unowned let site: SiteModel + @ObservationIgnored private let tiles: TileCache + @ObservationIgnored private var ask: LiveAsk? + @ObservationIgnored private var active = 0 + @ObservationIgnored private var token = 0 + + public init(site: SiteModel, tiles: TileCache) { + self.site = site + self.tiles = tiles + } + + public func activate() { + active += 1 + if ask == nil { + ask = LiveAsk(site: site, want: { [weak self] in + guard let self, self.active > 0, self.site.documentVisible else { return nil } + // The window ends now, so which five minutes "now" is belongs + // in the name; otherwise the right edge says "now" all day. + return "\(self.range.rawValue) \(Int(self.site.nowMs / 300_000))" + }, ask: { [weak self] in try await self?.load() }) + } + ask?.evaluate() + } + + public func deactivate() { + active = max(0, active - 1) + ask?.evaluate() + } + + public func select(_ next: Range) { + // The cursor is an index into this frame, and the same index in + // another range is another moment. + if next != range { cursor = nil } + range = next + ask?.evaluate() + } + + /// The time under the cursor. + public var cursorAtMs: Double? { + guard let frame, let cursor else { return nil } + return frame.time(at: cursor) + } + + /// The value under the cursor, or the latest. + public func value(_ name: String) -> Double? { + guard let frame, frame.points > 0 else { return nil } + return frame.value(name, at: cursor ?? frame.points - 1).map(Double.init) + } + + public var missingMs: Double { gaps.reduce(0) { $0 + ($1.toMs - $1.fromMs) } } + + func load() async throws { + token += 1 + let mine = token + let names = Self.series + let to = site.scheduler.nowMs + let from = to - range.spanMs + let plan = HistoryGeometry.plan(range.res, fromMs: from, toMs: to, maxPoints: Self.maxPoints) + let cached = tiles.get(plan.tiles.map(\.tileId)) + var have = cached + func show() { frame = HistoryGeometry.clip(HistoryGeometry.assemble(plan, names: names, tiles: have), fromMs: from, toMs: to) } + // Whatever is on disk goes up now. + if !cached.isEmpty { show() } + + loading = true + error = nil + defer { if mine == token { loading = false } } + do { + let end = try await site.history( + HistQuery(series: names, res: range.res, fromMs: from, toMs: to, have: cached.values.map { ($0.tileId, $0.etag) }, maxPoints: Self.maxPoints) + ) { [weak self] chunk in + guard let self, mine == self.token else { return } + have[chunk.tileId] = chunk + self.tiles.put(chunk) + } + guard mine == token else { return } + show() + resActual = end.resActual + gaps = end.gaps + loaded = true + tiles.flush(nowMs: to) + } catch { + guard mine == token else { return } + if !have.isEmpty { show() } + // Both sentences are about the wire, never about the house: an + // answer that did not arrive says nothing about what was recorded. + self.error = have.isEmpty ? "Nothing through yet — your box is out of reach" : "Not up to date — your box is out of reach" + throw error + } + } + + /// "5 min trend · 5 min box readings" or "One point every hour". + public var note: String { + if let error { return error } + if loading { return "Reading your box…" } + guard let frame, resActual != nil else { return "" } + let step = frame.stepMs + var text: String + if step < 3_600_000 { + text = "One point every \(Int((step / 60_000).rounded())) minutes, from your box" + } else { + let hours = Int((step / 3_600_000).rounded()) + text = "One point every \(hours == 1 ? "hour" : hours == 24 ? "day" : "\(hours) hours"), from your box" + } + if missingMs > 0 { + let m = missingMs + text += " · \(m < 3_600_000 ? "\(max(1, Int((m / 60_000).rounded()))) min" : "\(Int((m / 3_600_000).rounded())) h") not recorded" + } + return text + } +} diff --git a/appleApp/FTWKit/Sources/FTWKit/State/LoadpointsModel.swift b/appleApp/FTWKit/Sources/FTWKit/State/LoadpointsModel.swift new file mode 100644 index 0000000..a8b17ea --- /dev/null +++ b/appleApp/FTWKit/Sources/FTWKit/State/LoadpointsModel.swift @@ -0,0 +1,347 @@ +import Foundation +import Observation + +/// The charger sheet. Everything on it is a fact the box served; the +/// controls express intent with an expiry, and the box decides. After any +/// outcome the charger is read again, because the box's account is the +/// truth to repaint from. +@Observable +@MainActor +public final class LoadpointsModel: Activatable { + public enum Control: Sendable { case hold, boost, soc, surplus } + public enum Outcome: Sendable { case hold, release, pause, boost, unboost, soc, surplusOn, surplusOff } + + public enum Command: Sendable { + case idle + case sending(Control) + case applied(Control, Outcome) + case unconfirmed(Control) + case failed(Control, String) + + public var isSending: Bool { if case .sending = self { return true }; return false } + } + + public private(set) var points: [Loadpoint] = [] + public private(set) var windows: [String: [Loadpoint.Window]] = [:] + public private(set) var planMissing = false + public private(set) var planPending = false + public private(set) var planOutdated = false + public private(set) var loading = false + /// Whether the box has ever answered. No answer is not an empty bay. + public private(set) var loaded = false + public private(set) var readAtMs: Double? + public private(set) var error: String? + public private(set) var command: Command = .idle + public private(set) var commandLoadpointID: String? + /// Applied choices stay on screen until a later read confirms them. + public private(set) var acceptedSoc: [String: Double] = [:] + public private(set) var acceptedSurplus: [String: Bool] = [:] + + // The sheet's drafts, held here so a reread never snaps a control from + // under a finger. + public var ampsDraft: [String: Int] = [:] + public var socDraft: [String: Double] = [:] + public var surplusDraft: [String: Bool] = [:] + + @ObservationIgnored private unowned let site: SiteModel + @ObservationIgnored private let charging: ChargingWatch? + @ObservationIgnored private var ask: LiveAsk? + @ObservationIgnored private var active = 0 + @ObservationIgnored private var token = 0 + @ObservationIgnored private var settle: Cancellable? + @ObservationIgnored private var inlinePlan = false + @ObservationIgnored private var reading: Task? + + public init(site: SiteModel, charging: ChargingWatch?) { + self.site = site + self.charging = charging + } + + public func activate() { + active += 1 + if ask == nil { + ask = LiveAsk(site: site, want: { [weak self] in + guard let self, self.active > 0, self.site.documentVisible, self.site.hasPassthrough else { return nil } + return "loadpoints \(Int(self.site.nowMs / 5_000))" + }, ask: { [weak self] in try await self?.load() }) + } + ask?.evaluate() + } + + public func deactivate() { + active = max(0, active - 1) + ask?.evaluate() + } + + /// Out of date: the read failed, the stream stopped, or it is old. + public var stale: Bool { + error != nil || site.session.phase != .streaming || (readAtMs.map { site.nowMs - $0 >= 15_000 } ?? false) + } + + // MARK: Reads + + public func loadChargers() async throws { + if let reading { + try await reading.value + return + } + let task = Task { @MainActor in try await self.readChargers() } + reading = task + defer { reading = nil } + try await task.value + } + + private func readChargers() async throws { + token += 1 + let mine = token + let socChoices = acceptedSoc + let surplusChoices = acceptedSurplus + loading = true + defer { if mine == token { loading = false } } + do { + let wire = try await site.callBox(.get, "/api/loadpoints") + guard mine == token else { return } + let list = wire?["loadpoints"]?.array ?? [] + points = list.map(Loadpoint.init) + inlinePlan = !points.isEmpty && points.allSatisfy(\.inlinePlan) + if inlinePlan { + planPending = points.contains { $0.planPending } + planOutdated = points.contains { $0.planOutdated } + windows = Dictionary(uniqueKeysWithValues: points.map { ($0.id, $0.planWindows) }) + planMissing = false + } + // A read that started before a command cannot confirm its choice. + acceptedSoc = acceptedSoc.filter { socChoices[$0.key] != $0.value } + acceptedSurplus = acceptedSurplus.filter { surplusChoices[$0.key] != $0.value } + loaded = true + readAtMs = site.scheduler.nowMs + error = nil + } catch { + guard mine == token else { return } + if let e = error as? BoxAPIError, points.isEmpty { + self.error = e.help + } else { + self.error = "Not up to date — your box is out of reach" + } + throw error + } + } + + /// The chargers, then the plan's windows for a box that reports them + /// separately. A failed plan read is a note, not a failure. + public func load() async throws { + try await loadChargers() + if inlinePlan { return } + let mine = token + do { + let wire = try await site.callBox(.get, "/api/mpc/plan") + guard mine == token else { return } + planPending = wire?["meta"]?["replanning"]?.bool == true + planOutdated = wire?["meta"]?["outdated"]?.bool == true + let actions = wire?["plan"]?["actions"]?.array ?? [] + var out = [String: [Loadpoint.Window]]() + for lp in points { + out[lp.id] = planPending || planOutdated || lp.planPending || lp.planOutdated ? [] : Self.chargeWindows(actions, loadpointID: lp.id) + } + windows = out + planMissing = false + } catch { + guard mine == token else { return } + planMissing = true + } + } + + /// Adjacent charging slots fold into one window; a person asks when it + /// will charge, not when the reason changes. + static func chargeWindows(_ actions: [JSON], loadpointID: String) -> [Loadpoint.Window] { + var out = [Loadpoint.Window]() + for a in actions { + guard let start = a["slot_start_ms"]?.number, let len = a["slot_len_min"]?.number, + let w = a["loadpoint_power_w"]?[loadpointID]?.number, w > 0 else { continue } + let end = start + len * 60_000 + if var last = out.last, start <= last.toMs { + last.toMs = end + last.peakW = max(last.peakW ?? 0, w) + out[out.count - 1] = last + } else { + out.append(Loadpoint.Window(fromMs: start, toMs: end, peakW: w, energyWh: nil)) + } + } + return out + } + + // MARK: Commands + + /// Charge now: a hold at a chosen current until Stop or an unplug. + public func chargeNow(_ lp: Loadpoint, amps: Int) async { + await send(Contract.opLoadpointHold, lp.id, [("id", .text(lp.id)), ("power_w", .number(EVText.watts(lp, amps: amps))), ("hold_s", .int(0)), ("phase_mode", .text(EVText.current(lp).phases == 1 ? "1p" : "3p"))], .hold, .hold, CommandText.help) + } + + public func pauseCharging(_ lp: Loadpoint) async { + await send(Contract.opLoadpointHold, lp.id, [("id", .text(lp.id)), ("power_w", .int(0)), ("hold_s", .int(0))], .hold, .pause, CommandText.help) + } + + /// Release the hold; the plan takes back over. + public func stopCharging(_ lp: Loadpoint) async { + await send(Contract.opLoadpointHold, lp.id, [("id", .text(lp.id)), ("clear", .bool(true))], .hold, .release, CommandText.help) + } + + /// Let the house battery push the car for a bounded while. + public func boost(_ lp: Loadpoint, reservePct: Int, durationS: Int) async { + await send(Contract.opLoadpointBoost, lp.id, [("id", .text(lp.id)), ("min_battery_soc_pct", .int(reservePct)), ("duration_s", .int(durationS))], .boost, .boost, CommandText.boostHelp) + } + + public func stopBoost(_ lp: Loadpoint) async { + await send(Contract.opLoadpointBoost, lp.id, [("id", .text(lp.id)), ("cancel", .bool(true))], .boost, .unboost, CommandText.boostHelp) + } + + /// Whole percent from the slider, a fraction to the box. + public func setSoc(_ lp: Loadpoint, pct: Double) async { + await send(Contract.opLoadpointSocSet, lp.id, [("id", .text(lp.id)), ("soc", .number(pct / 100))], .soc, .soc, CommandText.socHelp) + } + + public func setSurplusOnly(_ lp: Loadpoint, _ on: Bool) async { + await send(Contract.opLoadpointSurplusOnlySet, lp.id, [("id", .text(lp.id)), ("surplus_only", .bool(on))], .surplus, on ? .surplusOn : .surplusOff, CommandText.help) + } + + private func send(_ op: String, _ id: String, _ args: [(String, CBOR)], _ of: Control, _ did: Outcome, _ help: (CmdResult) -> String) async { + if command.isSending { return } + settle?.cancel() + commandLoadpointID = id + command = .sending(of) + do { + let result = try await site.command(op, args: args) + switch result.state { + case .applied: + command = .applied(of, did) + if of == .soc, let soc = args.first(where: { $0.0 == "soc" })?.1.double { acceptedSoc[id] = (soc * 100).rounded() } + if of == .surplus, let on = args.first(where: { $0.0 == "surplus_only" })?.1.bool { acceptedSurplus[id] = on } + case .unconfirmed: + command = .unconfirmed(of) + default: + command = .failed(of, help(result)) + } + } catch let e as CommandError { + command = .failed(of, e.help) + } catch { + command = .failed(of, "FTW did not confirm the request. Reading its current state…") + } + if case .applied = command { + settle = site.scheduler.after(6_000) { [weak self] in self?.command = .idle } + } + try? await loadChargers() + charging?.refresh() + } + + // MARK: Configure routes: schedule, vehicle + + /// Save a goal in one PUT, so it costs one ceremony at most. + public func saveSchedule(_ lp: Loadpoint, socPct: Double, hour: Int, minute: Int, recurring: Bool, days: Int, surplusUnlockPct: Double, calendar: Calendar = .current) async throws { + guard let minUTC = EVText.minuteUTC(hour: hour, minute: minute, calendar: calendar) else { + throw BoxAPIError(code: "E_BAD_TIME", help: "Choose a valid ready time.", status: nil) + } + guard !recurring || days != 0 else { throw BoxAPIError(code: "E_NO_DAYS", help: "Choose at least one day.", status: nil) } + guard (10...100).contains(socPct) else { throw BoxAPIError(code: "E_BAD_SOC", help: "Choose a charge target from 10 to 100 %.", status: nil) } + guard (0...100).contains(surplusUnlockPct) else { throw BoxAPIError(code: "E_BAD_SOC", help: "Choose a home battery level from 1 to 100 %.", status: nil) } + _ = try await site.callBox(.put, "/api/loadpoints/\(Self.escape(lp.id))/schedule", body: [ + "soc": .number(socPct / 100), + "time_of_day_min_utc": .number(Double(minUTC)), + "recurring": .bool(recurring), + "days": .number(Double(!recurring || days == 0x7f ? 0 : days & 0x7f)), + "surplus_unlock_bat_soc": .number(surplusUnlockPct / 100), + ]) + try? await loadChargers() + charging?.refresh() + } + + public func removeSchedule(_ lp: Loadpoint) async throws { + _ = try await site.callBox(.delete, "/api/loadpoints/\(Self.escape(lp.id))/schedule") + // The DELETE confirms the removal even if the reread fails. + if let i = points.firstIndex(where: { $0.id == lp.id }) { + points[i].schedule = nil + points[i].targetSocPct = nil + } + try? await loadChargers() + charging?.refresh() + } + + /// The car's usable battery size, 1 to 300 kWh. + public func setCapacity(_ lp: Loadpoint, kwh: Double) async throws -> String { + guard kwh.isFinite, (1...300).contains(kwh) else { + throw BoxAPIError(code: "E_BAD_CAPACITY", help: "Enter the usable battery size from 1 to 300 kWh.", status: nil) + } + let wh = (kwh * 1000).rounded() + _ = try await site.callBox(.post, "/api/loadpoints/\(Self.escape(lp.id))/vehicle", body: ["capacity_wh": .number(wh)]) + do { + try await load() + } catch { + return "Battery size saved. Current charging status is unavailable." + } + charging?.refresh() + if let active = points.first(where: { $0.id == lp.id })?.vehicleCapacityWh, active != wh { + return "Saved as the usual battery size. This session uses \(EnergyFormat.short(active / 1000)) kWh." + } + return "Battery size saved. The plan uses this size for its estimates." + } + + static func escape(_ id: String) -> String { + id.addingPercentEncoding(withAllowedCharacters: .urlPathAllowed.subtracting(CharacterSet(charactersIn: "/"))) ?? id + } + + // MARK: What the sheet shows + + public func socShown(_ lp: Loadpoint) -> Double { + socDraft[lp.id] ?? acceptedSoc[lp.id] ?? lp.socPct ?? EVText.socDefaultPct + } + + public func surplusShown(_ lp: Loadpoint) -> Bool { + surplusDraft[lp.id] ?? acceptedSurplus[lp.id] ?? lp.surplusOnly + } + + /// The slider's amps: the draft, else the running hold, else the ceiling. + public func ampsShown(_ lp: Loadpoint) -> Int { + let range = EVText.current(lp) + let held: Int? = lp.manualActive && !lp.manualRestoreUnconfirmed && !EVText.isPaused(lp) ? lp.manualChargeW.map { EVText.amps(lp, watts: $0) } : nil + return min(range.maxA, max(range.minA, ampsDraft[lp.id] ?? held ?? range.maxA)) + } + + /// One sentence per thing the box did, under the control that asked. + public func outcomeSentence(_ did: Outcome, _ lp: Loadpoint) -> String { + switch did { + case .hold: return stale ? "Waiting for current charger status." : lp.manual != nil ? EVText.status(lp) : "FTW received your charge request. Waiting for charger status." + case .release: return "The plan decides when to charge." + case .pause: return EVText.status(lp) + case .boost: return "Battery boost selected. The power readings show what the house battery supplies." + case .unboost: return "Boost stopped — the plan decides again." + case .soc: + if let v = acceptedSoc[lp.id] { return "Charge level accepted: \(Int(v)) %. Waiting for updated charging status." } + let pct = Int(lp.socPct ?? socShown(lp)) + let base = lp.socRetention == "error" ? "Charge level updated: \(pct) %. It could not be saved for a box restart." : "Charge level saved: \(pct) %." + let tail = lp.schedule == nil && !lp.manualActive && !lp.surplusOnly ? " Set a ready time, or choose Charge now." : planMissing ? " Charging times are not available yet." : "" + return base + tail + case .surplusOn: return acceptedSurplus[lp.id] != nil ? "Solar rule accepted. Waiting for updated charging status." : "Solar rule saved. The plan uses spare solar only." + case .surplusOff: return acceptedSurplus[lp.id] != nil ? "Solar rule accepted. Waiting for updated charging status." : "Solar rule saved. The plan may use grid power again." + } + } + + /// The outcome line under one control, or nil. + public func outcome(for control: Control, _ lp: Loadpoint) -> String? { + guard commandLoadpointID == lp.id else { + if control == .soc, acceptedSoc[lp.id] != nil { return outcomeSentence(.soc, lp) } + if control == .surplus, acceptedSurplus[lp.id] != nil { return "Solar rule accepted. Waiting for updated charging status." } + return nil + } + switch command { + case .applied(let of, let did) where of == control && (of != .hold || did == .release): + return outcomeSentence(did, lp) + case .unconfirmed(let of) where of == control: + return "FTW received the request. Its result is not confirmed yet." + case .failed(let of, let help) where of == control: + return help + default: + if control == .soc, acceptedSoc[lp.id] != nil { return outcomeSentence(.soc, lp) } + if control == .surplus, acceptedSurplus[lp.id] != nil { return "Solar rule accepted. Waiting for updated charging status." } + return nil + } + } +} diff --git a/appleApp/FTWKit/Sources/FTWKit/State/NowModels.swift b/appleApp/FTWKit/Sources/FTWKit/State/NowModels.swift new file mode 100644 index 0000000..fa38b09 --- /dev/null +++ b/appleApp/FTWKit/Sources/FTWKit/State/NowModels.swift @@ -0,0 +1,295 @@ +import Foundation +import Observation + +/// A screen that is on screen. Models only ask the box for what someone can +/// see; a hidden tab keeps its state and stops asking. +@MainActor +public protocol Activatable: AnyObject { + func activate() + func deactivate() +} + +/// Repeats a read on a fixed period while `shouldRun` holds, and reports a +/// retained answer as out of date once it is older than `maxAgeMs`. +@MainActor +final class Poller { + private let scheduler: Scheduler + private let periodMs: Double + private let shouldRun: @MainActor () -> Bool + private let work: @MainActor () async -> Void + private var timer: Cancellable? + private var running = false + private var busy = false + private var again = false + + init(scheduler: Scheduler, periodMs: Double, shouldRun: @escaping @MainActor () -> Bool, work: @escaping @MainActor () async -> Void) { + self.scheduler = scheduler + self.periodMs = periodMs + self.shouldRun = shouldRun + self.work = work + } + + func start() { + guard !running else { return } + running = true + tick() + } + + func stop() { + running = false + timer?.cancel() + } + + /// Read now, rather than at the next period. A confirmed action should + /// reach the screen before its next timer. + func refresh() { + if busy { again = true } else if running { tick() } + } + + private func tick() { + timer?.cancel() + guard running else { return } + busy = true + again = false + Task { @MainActor [weak self] in + guard let self else { return } + if self.shouldRun() { await self.work() } + self.busy = false + guard self.running else { return } + self.timer = self.scheduler.after(self.again ? 0 : self.periodMs) { [weak self] in self?.tick() } + } + } +} + +/// The dashboard's own live document, GET /api/status, every two seconds +/// while the Now screen is up. Per-driver nodes, energy today, the fuse. +@Observable +@MainActor +public final class StatusWatch: Activatable { + public static let maxAgeMs: Double = 15_000 + + public private(set) var status: JSON? + public private(set) var receivedAtMs: Double? + public private(set) var fresh = false + + @ObservationIgnored private unowned let site: SiteModel + @ObservationIgnored private var poller: Poller! + @ObservationIgnored private var expiry: Cancellable? + @ObservationIgnored private var active = 0 + + public init(site: SiteModel) { + self.site = site + poller = Poller(scheduler: site.scheduler, periodMs: 2_000, shouldRun: { [weak self] in + guard let self else { return false } + return self.site.documentVisible && self.site.session.phase == .streaming && self.site.hasPassthrough + }, work: { [weak self] in await self?.read() }) + } + + public func activate() { + active += 1 + if active == 1 { poller.start() } + } + + public func deactivate() { + active = max(0, active - 1) + if active == 0 { + poller.stop() + fresh = false + } + } + + private func read() async { + do { + let started = site.scheduler.nowMs + guard let json = try await site.callBox(.get, "/api/status"), json.object != nil else { throw BoxAPIError(code: "E_BAD_BODY", help: "", status: nil) } + let took = site.scheduler.nowMs - started + status = json + receivedAtMs = site.scheduler.nowMs + fresh = took < Self.maxAgeMs && site.session.phase == .streaming + expiry?.cancel() + expiry = site.scheduler.after(max(0, Self.maxAgeMs - took)) { [weak self] in self?.fresh = false } + } catch { + expiry?.cancel() + fresh = false + } + } +} + +/// The chargers, read every five seconds for the Now diagram and the car +/// notice. Hidden phones stop polling. +@Observable +@MainActor +public final class ChargingWatch: Activatable { + public private(set) var points: [Loadpoint] = [] + public private(set) var fresh = false + + @ObservationIgnored private unowned let site: SiteModel + @ObservationIgnored private var poller: Poller! + @ObservationIgnored private var expiry: Cancellable? + @ObservationIgnored private var active = 0 + + public init(site: SiteModel) { + self.site = site + poller = Poller(scheduler: site.scheduler, periodMs: 5_000, shouldRun: { [weak self] in + guard let self else { return false } + if !(self.site.documentVisible && self.site.session.phase == .streaming && self.site.hasPassthrough) { + self.fresh = false + return false + } + return true + }, work: { [weak self] in await self?.read() }) + } + + public func activate() { + active += 1 + if active == 1 { poller.start() } + } + + public func deactivate() { + active = max(0, active - 1) + if active == 0 { + poller.stop() + fresh = false + } + } + + public func refresh() { poller.refresh() } + + /// Charger watts for the diagram, zero unless fresh. + public var chargeW: Double { fresh ? Flow.loadpointChargeW(points) : 0 } + + private func read() async { + do { + guard let list = try await site.callBox(.get, "/api/loadpoints")?["loadpoints"]?.array else { + throw BoxAPIError(code: "E_BAD_BODY", help: "", status: nil) + } + let next = list.map(Loadpoint.init).map { lp -> Loadpoint in + // A charger that went out of touch keeps its last known cable. + var lp = lp + if lp.charger?.available == false, points.first(where: { $0.id == lp.id })?.pluggedIn == true { lp.pluggedIn = true } + return lp + } + points = next + fresh = site.documentVisible && site.session.phase == .streaming + expiry?.cancel() + expiry = site.scheduler.after(15_000) { [weak self] in self?.fresh = false } + } catch { + fresh = false + } + } +} + +/// Prices across today and tomorrow, from local midnight, asked for again +/// when the day turns and when tomorrow's rates publish. +@Observable +@MainActor +public final class PriceModel: Activatable { + static let horizonMs: Double = 48 * 3_600_000 + static let publishHour = 14 + + public private(set) var prices: Prices? + public private(set) var fromMs: Double = 0 + + @ObservationIgnored private unowned let site: SiteModel + @ObservationIgnored private var ask: LiveAsk? + @ObservationIgnored private var active = 0 + @ObservationIgnored private var generation = 0 + @ObservationIgnored private let calendar: Calendar + + public init(site: SiteModel, calendar: Calendar = .current) { + self.site = site + self.calendar = calendar + } + + public var currency: String { prices?.currency ?? "SEK" } + public var hasHole: Bool { prices.map { PriceUnits.hasHole($0.slots, fromMs: fromMs) } ?? false } + + func midnight(_ ms: Double) -> Double { + calendar.startOfDay(for: Date(timeIntervalSince1970: ms / 1000)).timeIntervalSince1970 * 1000 + } + + /// The window, named by what would make it out of date. + var wanted: String? { + guard active > 0, site.session.caps.contains(Contract.capPriceSpot) else { return nil } + let now = Date(timeIntervalSince1970: site.nowMs / 1000) + let hour = calendar.component(.hour, from: now) + return "\(midnight(site.nowMs))/\(hour >= Self.publishHour ? "published" : "pending")" + } + + public func activate() { + active += 1 + if ask == nil { + ask = LiveAsk(site: site, want: { [weak self] in + guard let self else { return nil } + // A box that stopped offering prices shows none; a window + // from yesterday is not today's. + if !self.site.session.caps.contains(Contract.capPriceSpot) { self.prices = nil } + if self.prices != nil, self.midnight(self.site.nowMs) != self.fromMs { self.prices = nil } + return self.wanted + }, ask: { [weak self] in try await self?.load() }) + } + ask?.evaluate() + } + + public func deactivate() { + active = max(0, active - 1) + ask?.evaluate() + } + + func load() async throws { + generation += 1 + let mine = generation + let from = midnight(site.nowMs) + do { + let wire = try await site.prices(fromMs: from, toMs: from + Self.horizonMs) + guard mine == generation else { return } + prices = wire + fromMs = from + } catch { + // A window already drawn stays drawn; today's prices are still + // today's. Rethrown so the ask heals. + guard mine == generation else { return } + throw error + } + } +} + +/// Money saved today and this week, GET /api/savings/daily. +@Observable +@MainActor +public final class SavingsModel: Activatable { + public private(set) var periods: Savings.Periods? + + @ObservationIgnored private unowned let site: SiteModel + @ObservationIgnored private var ask: LiveAsk? + @ObservationIgnored private var active = 0 + @ObservationIgnored private let calendar: Calendar + + public init(site: SiteModel, calendar: Calendar = .current) { + self.site = site + self.calendar = calendar + } + + public func activate() { + active += 1 + if ask == nil { + ask = LiveAsk(site: site, want: { [weak self] in + guard let self, self.active > 0, self.site.hasPassthrough else { return nil } + return "savings \(self.calendar.startOfDay(for: Date(timeIntervalSince1970: self.site.nowMs / 1000)).timeIntervalSince1970)" + }, ask: { [weak self] in try await self?.load() }) + } + ask?.evaluate() + } + + public func deactivate() { + active = max(0, active - 1) + ask?.evaluate() + } + + func load() async throws { + let wire = try await site.callBox(.get, "/api/savings/daily", query: ["days": "7"]) + let days = (wire?["days"]?.array ?? []).compactMap(Savings.day) + let next = Savings.periods(days) + periods = next.today.available || next.week.available ? next : nil + } +} diff --git a/appleApp/FTWKit/Sources/FTWKit/State/PairModel.swift b/appleApp/FTWKit/Sources/FTWKit/State/PairModel.swift new file mode 100644 index 0000000..7de035c --- /dev/null +++ b/appleApp/FTWKit/Sources/FTWKit/State/PairModel.swift @@ -0,0 +1,134 @@ +import Foundation +import Observation + +/// Pairing, the first thing anyone sees. Two taps: scan, Face ID. Everything +/// that can fail does so before the passkey prompt. +@Observable +@MainActor +public final class PairModel { + public enum Stage: Equatable, Sendable { + case intro, scanning, pairing, recovering, choosing, demoing + } + + public var stage: Stage = .intro + /// A sentence, when something did not work. + public private(set) var message: String? + /// The box a link points at, by the same six characters the Box screen + /// names a paired box by. + public private(set) var offeredFingerprint: String? + public private(set) var recovered: [Escrow.Recovered] = [] + /// This phone already holds a key and a home: something local went + /// missing, not a stranger arriving. + public private(set) var known: StoredSite? + + @ObservationIgnored private unowned let app: AppModel + + public init(app: AppModel) { + self.app = app + if app.vault.isEnrolled { known = app.sites.all().first } + if let link = app.offeredLink { + do { + offeredFingerprint = SiteList.fingerprint(try Enrollment.parse(scanned: link).boxStaticPublic) + } catch { + message = "That link is not an FTW pairing code." + } + } + } + + /// Opening the saved home needs a key and a row, and no problem that + /// says one of them is broken. + public var canOpen: Bool { known != nil && app.connectHelp == nil } + + /// Scanned with the camera or pasted: a deliberate act, so it pairs. + public func pair(_ raw: String) async { + stage = .pairing + message = nil + do { + let result = try await app.pairing.pair(scanned: raw) + app.paired(result.site.siteId) + } catch is PasskeyCancelled { + // A dismissed sheet is not a fault and gets no error voice. + stage = .intro + } catch let e as HelpfulError { + stage = .intro + message = e.help + } catch { + stage = .intro + message = "That didn't work. Try scanning again." + } + } + + /// Pair with the link that arrived, now that someone said so. + public func acceptOffer() async { + guard let link = app.offeredLink else { return } + await pair(link) + } + + public func declineOffer() { + app.offeredLink = nil + offeredFingerprint = nil + } + + /// Ask the passkey what Sourceful is holding. Costs a prompt, so it is a + /// button and never a check on arrival. + public func recover() async { + stage = .recovering + message = nil + do { + recovered = try await app.escrow.recover(app.passkeys) + if recovered.isEmpty { + stage = .intro + message = "Nothing was saved for this passkey. Open Settings → FTW app in your box dashboard and scan a new pairing code instead." + return + } + stage = .choosing + } catch is PasskeyCancelled { + stage = .intro + } catch let e as HelpfulError { + stage = .intro + message = e.help + } catch { + stage = .intro + message = "That didn't work. Open Settings → FTW app in your box dashboard and scan a new pairing code instead." + } + } + + public func adopt(_ home: Escrow.Recovered) { + stage = .pairing + do { + let id = try app.escrow.adopt(home, nowMs: app.scheduler.nowMs) + app.paired(id) + } catch let e as HelpfulError { + stage = .intro + message = e.help + } catch { + stage = .intro + message = "That didn't work. Try scanning the code instead." + } + } + + public func openKnown() { + guard let known else { return } + app.paired(known.siteId) + } + + /// A picture was read and held no pairing code. + public func noCodeFound() { + stage = .intro + message = "There is no FTW pairing code in that picture. Try a closer screenshot of the QR." + } + + public func tryDemo() { + stage = .demoing + app.startDemo() + } + + public func cancel() { + stage = .intro + message = nil + } + + public func dismiss() { + app.recovering = false + } +} diff --git a/appleApp/FTWKit/Sources/FTWKit/State/PlanModel.swift b/appleApp/FTWKit/Sources/FTWKit/State/PlanModel.swift new file mode 100644 index 0000000..5f7077b --- /dev/null +++ b/appleApp/FTWKit/Sources/FTWKit/State/PlanModel.swift @@ -0,0 +1,152 @@ +import Foundation +import Observation + +/// The plan, and changing how the site is run. The plan lives on session +/// state because the box pushes a fresh one unasked after a mode change +/// made from any phone. +@Observable +@MainActor +public final class PlanModel: Activatable { + public enum Command: Equatable, Sendable { + case idle + case sending(String) + case applied(String) + case unconfirmed(String) + case failed(String) + } + + /// How long a settled outcome stays before the screen goes quiet. + static let settleMs: Double = 4_000 + + public private(set) var loading = false + /// A sentence, never a code. + public private(set) var problem: String? + public private(set) var command: Command = .idle + + @ObservationIgnored private unowned let site: SiteModel + @ObservationIgnored private var ask: LiveAsk? + @ObservationIgnored private var active = 0 + @ObservationIgnored private var want = 0 + @ObservationIgnored private var settle: Cancellable? + + public init(site: SiteModel) { + self.site = site + } + + public func activate() { + active += 1 + if ask == nil { + ask = LiveAsk(site: site, want: { [weak self] in + guard let self, self.active > 0 else { return nil } + return "plan \(self.want)" + }, ask: { [weak self] in try await self?.load() }) + } + ask?.evaluate() + } + + public func deactivate() { + active = max(0, active - 1) + ask?.evaluate() + } + + public var plan: Plan? { site.session.plan } + + /// Every mode the box accepts, in its order. Hidden ones are valid over + /// the API but never buttons. + public var modes: [ModeInfo] { site.session.modes.filter { $0.tier != "hidden" } } + public var primaryModes: [ModeInfo] { modes.filter { $0.tier == "primary" } } + public var advancedModes: [ModeInfo] { modes.filter { $0.tier == "advanced" } } + + /// The mode the box reports, which is the only one that counts. + public var actualMode: String? { + guard let i = site.session.fields[Contract.FID.mode].map({ Int($0) }), i >= 0, i < site.session.modes.count else { return nil } + return site.session.modes[i].key + } + + /// The pending choice while one is in flight, else the real one. A + /// refusal snaps back because this never overrides what the box said. + public var shownMode: String? { + switch command { + case .sending(let m), .applied(let m), .unconfirmed(let m): return m + default: return actualMode + } + } + + public var inManual: Bool { + guard let m = shownMode else { return false } + return advancedModes.contains { $0.key == m } + } + + /// The way back from a manual fallback: the box's first primary mode. + public var planHome: ModeInfo? { primaryModes.first } + + /// Both have to hold: the box offers dispatch, and this enrolment carries + /// the scope the box checks. Drawing a control a viewer will be refused + /// is the one thing this app must not do. + public var canControl: Bool { + site.session.caps.contains(Contract.capPlanDispatch) && site.session.scopes.contains(Contract.scopeModeWrite) + } + + public enum NoControl: Sendable { case box, role } + + /// Nil until the box has answered: before its hello, "this box can't do + /// that" would be a sentence about a box that has said nothing. + public var whyNoControl: NoControl? { + guard site.heardFromBox, !canControl else { return nil } + return site.session.scopes.contains(Contract.scopeModeWrite) ? .box : .role + } + + func load() async throws { + loading = true + problem = nil + defer { loading = false } + do { + _ = try await site.plan() + } catch { + problem = "Couldn't get the plan from your box. Still trying." + throw error + } + } + + /// Optimistic on screen, never in the model. + public func setMode(_ mode: String) async { + if case .sending = command { return } + if mode == shownMode { return } + settle?.cancel() + command = .sending(mode) + do { + let result = try await site.command(Contract.opSetMode, args: [("mode", .text(mode))]) + switch result.state { + case .applied: + command = .applied(mode) + followPlan() + case .unconfirmed: + command = .unconfirmed(mode) + default: + command = .failed(CommandText.help(result)) + } + } catch let e as CommandError { + command = .failed(e.help) + } catch { + command = .failed("That didn't go through. Try again.") + } + settle = site.scheduler.after(Self.settleMs) { [weak self] in self?.command = .idle } + } + + /// The box acknowledges a mode before its optimiser has replanned, so the + /// plan is asked for again every three seconds, for thirty at most, + /// until its revision moves. + private func followPlan() { + let before = plan?.rev + func step(_ n: Int) { + guard n < 10, plan?.rev == before else { return } + want += 1 + ask?.evaluate() + site.scheduler.after(3_000) { [weak self] in + guard self != nil else { return } + step(n + 1) + } + } + step(0) + } +} diff --git a/appleApp/FTWKit/Sources/FTWKit/State/SealedCopyModel.swift b/appleApp/FTWKit/Sources/FTWKit/State/SealedCopyModel.swift new file mode 100644 index 0000000..46dc3d1 --- /dev/null +++ b/appleApp/FTWKit/Sources/FTWKit/State/SealedCopyModel.swift @@ -0,0 +1,64 @@ +import Foundation +import Observation + +/// The spare key on the Box screen: whether Sourceful holds a sealed copy of +/// this home. Written as the web app does it: mark the home, seal the set, +/// and put the mark back when the seal did not land, so the switch never +/// says something the escrow does not hold. +@Observable +@MainActor +public final class SealedCopyModel { + public enum Stage: Sendable { case idle, working, failed } + + public private(set) var kept: Bool + public private(set) var stage: Stage = .idle + /// A sentence, when the last try did not work. + public private(set) var problem: String? + + @ObservationIgnored private unowned let app: AppModel + @ObservationIgnored private let siteId: String + + init(app: AppModel, siteId: String) { + self.app = app + self.siteId = siteId + kept = app.sites.get(siteId)?.escrow == true + } + + /// Read the mark again. Pairing seals its copy in the background, so the + /// answer can land after this model was made. + public func reload() { + guard stage != .working else { return } + kept = app.sites.get(siteId)?.escrow == true + } + + public func set(_ on: Bool) async { + guard stage != .working else { return } + stage = .working + problem = nil + app.escrow.mark(siteId, on) + do { + let wrapping = try await app.vault.unlockWrappingKey(app.passkeys) + let outcome = await app.escrow.save(wrapping) + if outcome == .saved || outcome == .cleared { + kept = on + stage = .idle + return + } + app.escrow.mark(siteId, !on) + stage = .failed + switch outcome { + case .unsupported: problem = "This phone has no passkey that can seal a copy. Everything else works exactly as it does now." + case .unreachable: problem = "That didn't reach Sourceful. Your home works either way. Try again when you're back online." + default: problem = "That didn't work. Your home works either way. Try again." + } + } catch is PasskeyCancelled { + // A dismissed sheet is an answer, not a fault. + app.escrow.mark(siteId, !on) + stage = .idle + } catch { + app.escrow.mark(siteId, !on) + stage = .failed + problem = "That didn't work. Try again." + } + } +} diff --git a/appleApp/FTWKit/Sources/FTWKit/State/SiteModel.swift b/appleApp/FTWKit/Sources/FTWKit/State/SiteModel.swift new file mode 100644 index 0000000..9257af5 --- /dev/null +++ b/appleApp/FTWKit/Sources/FTWKit/State/SiteModel.swift @@ -0,0 +1,330 @@ +import Foundation +import Observation + +/// The bridge between the session and the screens. +/// +/// The session owns protocol state; this exposes it, restores the cached +/// snapshot on start, keeps freshness honest and derives what the screens +/// ask for. Nothing above this touches a frame. +@Observable +@MainActor +public final class SiteModel { + /// Fields the Now screen draws. Their sources drive the freshness band. + static let nowFids = [Contract.FID.gridW, Contract.FID.pvW, Contract.FID.batteryW, Contract.FID.batterySoc, Contract.FID.loadW, Contract.FID.evW] + /// A 1 Hz stream is always a moment behind and a dropped tick is normal + /// on a phone; past a couple of beats the silence is real. + static let streamQuietAfterMs: Double = 3_000 + /// How long a foreground stream gets to prove its socket still works. + public static let foregroundFrameDeadlineMs: Double = 2_500 + static let recentWindowMs: Double = 130_000 + static let snapshotIntervalMs: Double = 15_000 + + public private(set) var session = SessionState() + /// Wall clock the cached view was captured. Nil when live. + public private(set) var cachedAtMs: Double? + public private(set) var lastFrameAtMs: Double? + public private(set) var documentVisible = true + /// The one clock screens depend on, so derived ages move on screen. + public private(set) var nowMs: Double + /// Import ceiling the optimiser defends, when known. + public var ceilingW: Double? + + public let siteId: String? + /// True for the demo: nothing is written, and the screens say so. + public let isDemo: Bool + + @ObservationIgnored let core: Session + @ObservationIgnored let scheduler: Scheduler + @ObservationIgnored private let files: SealedFiles? + @ObservationIgnored private var attemptStartedAtMs: Double + @ObservationIgnored private var ticker: Cancellable? + @ObservationIgnored private var resumeTimer: Cancellable? + @ObservationIgnored private var resumeWaiting = false + @ObservationIgnored private var recent: [(t: Double, v: [Int: Double])] = [] + @ObservationIgnored private var lastSnapshotMs: Double = 0 + @ObservationIgnored private var snapshotTimer: Cancellable? + @ObservationIgnored private var destroyed = false + @ObservationIgnored private var listeners: [Int: @MainActor () -> Void] = [:] + @ObservationIgnored private var nextListener = 0 + /// Runs the passkey ceremony a write needs. Injected by the shell. + @ObservationIgnored public var stepUp: (@MainActor () async -> StepUpOutcome)? + + public init(siteId: String?, build: String, ua: String, scheduler: Scheduler, files: SealedFiles?, isDemo: Bool = false) { + self.siteId = siteId + self.scheduler = scheduler + self.files = files + self.isDemo = isDemo + nowMs = scheduler.nowMs + attemptStartedAtMs = scheduler.nowMs + core = Session(build: build, ua: ua, scheduler: scheduler) + core.onChange = { [weak self] s in self?.onSession(s) } + } + + // MARK: Lifecycle + + /// Paint from cache, then let the shell connect. The cache is a carrier, + /// not a failure: it is how the first frame has something honest. + public func start() { + if let siteId, let cached = files?.readJSON(CachedSnapshot.self, "snapshot-\(siteId)"), cached.siteId == siteId { + core.restore(cached) + cachedAtMs = cached.savedAtMs + } + startTicker() + } + + private func startTicker() { + ticker?.cancel() + ticker = scheduler.after(1_000) { [weak self] in + guard let self, !self.destroyed else { return } + self.nowMs = self.scheduler.nowMs + self.notify() + self.startTicker() + } + } + + /// Attach a carrier. Refused once the model is gone, so a slow connect + /// can never hand an old home's stream to a new one. + @discardableResult + public func connect(_ carrier: Carrier) -> Bool { + if destroyed { + carrier.close(reason: "superseded connection") + return false + } + core.connect(carrier) + if documentVisible { beginResume(immediate: true) } + return true + } + + public func destroy() { + destroyed = true + ticker?.cancel() + resumeTimer?.cancel() + snapshotTimer?.cancel() + core.onChange = nil + core.close() + listeners = [:] + } + + /// Match the stream's cadence to whether anyone can see it. + public func setVisible(_ visible: Bool) { + let was = documentVisible + documentVisible = visible + core.setTelemetryHz(visible ? 1 : 0.2) + if !visible { + cancelResume() + persistNow() + return + } + if !was { beginResume(immediate: false) } + } + + /// A new network path should replace the old one without waiting. + public func networkOnline() { + guard documentVisible else { return } + beginResume(immediate: true) + } + + /// Pull to refresh: ask for a fresh stream in place. + public func refresh() { + core.wake() + } + + // MARK: Observation for feature models + + /// Called after every session change and every clock tick. + @discardableResult + public func observe(_ f: @escaping @MainActor () -> Void) -> Int { + nextListener += 1 + listeners[nextListener] = f + return nextListener + } + + public func unobserve(_ token: Int) { + listeners[token] = nil + } + + private func notify() { + for f in listeners.values { f() } + } + + // MARK: Session changes + + private func onSession(_ s: SessionState) { + let previous = session + if previous.phase == .streaming, s.phase != .streaming { + attemptStartedAtMs = scheduler.nowMs + } + // Only a streaming session is a reading arriving. Restoring the cache + // and answering hello both move the clock and carry no reading. + if s.phase == .streaming, previous.phase != .streaming || s.uptimeMs != previous.uptimeMs { + let now = scheduler.nowMs + lastFrameAtMs = now + finishResume() + recordRecent(s.fields, now) + } + session = s + if s.phase == .streaming { + cachedAtMs = nil + scheduleSnapshot() + } + notify() + } + + private func scheduleSnapshot() { + guard siteId != nil, files != nil, snapshotTimer == nil else { return } + let due = max(0, lastSnapshotMs + Self.snapshotIntervalMs - scheduler.nowMs) + snapshotTimer = scheduler.after(due) { [weak self] in + guard let self else { return } + self.snapshotTimer = nil + self.persistNow() + } + } + + /// Write the last streaming state now, before the app can be suspended. + public func persistNow() { + guard let siteId, let files, !destroyed, session.phase == .streaming || !session.fields.isEmpty, cachedAtMs == nil else { return } + lastSnapshotMs = scheduler.nowMs + files.writeJSON("snapshot-\(siteId)", CachedSnapshot(siteId: siteId, savedAtMs: scheduler.nowMs, state: session)) + } + + // MARK: Waking after sleep + + private func beginResume(immediate: Bool) { + guard !destroyed, documentVisible else { return } + resumeTimer?.cancel() + resumeWaiting = true + if immediate || session.phase != .streaming || lastFrameAtMs == nil { + core.wake() + } + let frameAtStart = lastFrameAtMs + resumeTimer = scheduler.after(Self.foregroundFrameDeadlineMs) { [weak self] in + guard let self, !self.destroyed, self.documentVisible, self.lastFrameAtMs == frameAtStart else { return } + self.core.wake() + } + } + + private func finishResume() { + guard resumeWaiting else { return } + resumeTimer?.cancel() + resumeWaiting = false + } + + private func cancelResume() { + resumeTimer?.cancel() + resumeWaiting = false + } + + // MARK: What the screens read + + public var role: String { session.role } + /// Whether to draw controls at all. Hiding is presentation; the box is + /// what refuses. + public var canConfigure: Bool { session.role == Contract.roleOwner } + /// Before the box's hello, role and caps are this app's assumptions, and + /// a sentence about the box built on them would be invented. + public var heardFromBox: Bool { session.heardFromBox } + public var paired: Bool { session.box != nil || !session.fields.isEmpty } + public var hasPassthrough: Bool { session.caps.contains(Contract.capAPIPassthrough) } + + /// How the readings on screen reach us. A carrier is claimed only once it + /// has delivered a reading: an open socket is evidence of nothing. + public var carrier: CarrierKind { + if session.phase == .streaming, lastFrameAtMs != nil { return session.carrier } + return cachedAtMs != nil ? .cache : .none + } + + public var connectionWaitMs: Double { max(0, nowMs - attemptStartedAtMs) } + + /// The sources behind what the Now screen draws, named by the box. + var nowSourceIDs: [String] { + var ids = [String]() + for fid in Self.nowFids where session.fields[fid] != nil { + if let src = session.dict[fid]?.srcId, !ids.contains(src) { ids.append(src) } + } + return ids + } + + public var srcState: SourceState { + if session.fields.isEmpty { return .never } + // Every source state was read off the disk: true when written. + if lastFrameAtMs == nil { return .stale } + let ids = nowSourceIDs + let worst: SourceState + if !ids.isEmpty { + worst = core.worstSourceState(ids) + } else if !session.sources.isEmpty { + worst = core.worstSourceState(Array(session.sources.keys)) + } else { + worst = .live + } + // A stream gone quiet is not live however healthy it looked. + if worst == .live, sinceLastFrameMs > 0 { return .lagging } + return worst + } + + public var sinceLastFrameMs: Double { + guard let at = lastFrameAtMs else { return 0 } + let since = nowMs - at + return since < Self.streamQuietAfterMs ? 0 : since + } + + /// Age of the oldest reading on screen. Nil means unknown, which is the + /// honest answer after a box restart. + public var ageMs: Double? { + let ages = nowSourceIDs.compactMap { core.ageOf($0) } + if carrier == .cache, let at = cachedAtMs { + let shelf = nowMs - at + return shelf + (ages.max() ?? 0) + } + guard let oldest = ages.max() else { return nil } + return oldest + sinceLastFrameMs + } + + /// Live is three claims at once: streaming, not the cache, and every + /// source answering. + public var isLive: Bool { + session.phase == .streaming && carrier != .cache && srcState == .live + } + + public var explanation: Explanation.Result { + Explanation.explain(fields: session.fields, dispatchBlockedBy: session.dispatchBlockedBy, ceilingW: ceilingW) + } + + public var socPercent: Double? { + session.fields[Contract.FID.batterySoc].map { ($0 / 10).rounded() } + } + + private func recordRecent(_ fields: [Int: Double], _ now: Double) { + var v = [Int: Double]() + for fid in Self.nowFids { if let x = fields[fid] { v[fid] = x } } + recent.append((now, v)) + let cutoff = now - Self.recentWindowMs + recent.removeAll { $0.t < cutoff } + } + + /// The last two minutes of one field, oldest first, so a live line opens + /// already drawn. + public func recentField(_ fid: Int) -> [(t: Double, v: Double)] { + recent.compactMap { r in r.v[fid].map { (r.t, $0) } } + } + + // MARK: Requests, through this layer + + public func history(_ q: HistQuery, onChunk: @escaping @MainActor (HistChunk) -> Void) async throws -> HistEnd { + try await core.history(q, onChunk: onChunk) + } + + public func plan() async throws -> Plan { try await core.plan() } + + public func prices(fromMs: Double, toMs: Double) async throws -> Prices { + try await core.prices(fromMs: fromMs, toMs: toMs) + } + + public func command(_ op: String, args: [(String, CBOR)], guards: [Guard] = []) async throws -> CmdResult { + try await core.command(op, args: args, guards: guards) + } + + public func api(_ req: APIRequest) async throws -> APIResponse { + try await core.api(req) + } +} diff --git a/appleApp/FTWKit/Sources/FTWKit/Store/SealedFiles.swift b/appleApp/FTWKit/Sources/FTWKit/Store/SealedFiles.swift new file mode 100644 index 0000000..ce4a707 --- /dev/null +++ b/appleApp/FTWKit/Sources/FTWKit/Store/SealedFiles.swift @@ -0,0 +1,138 @@ +import Foundation + +/// Sealed files for what the app caches between launches: the last readings +/// and the history tiles. +/// +/// AES-GCM under a cache key kept in the secure store, and deliberately not +/// behind a passkey: a cold start must paint before any Face ID prompt. The +/// honest claim is that the files resist an offline read of the disk, not +/// someone holding the unlocked phone, who can open the app and look. +/// +/// A file that will not open is treated as absent. Every file here is a +/// cache: the box holds the record. +@MainActor +public final class SealedFiles { + static let keyName = "cache-key" + private let directory: URL + private let store: SecureStore + + public init(directory: URL, store: SecureStore) { + self.directory = directory + self.store = store + } + + /// The app's own cache directory, created on first use. + public static func defaultDirectory() -> URL { + let base = FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask).first + ?? URL(fileURLWithPath: NSTemporaryDirectory()) + return base.appendingPathComponent("FTW", isDirectory: true) + } + + private var key: Bytes { + if let k = store.get(Self.keyName), k.count == 32 { return k } + let k = randomBytes(32) + store.put(Self.keyName, k) + return k + } + + private func url(_ name: String) -> URL { + directory.appendingPathComponent(name.replacingOccurrences(of: "/", with: "_")) + } + + public func write(_ name: String, _ plain: Bytes) { + do { + try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) + let nonce = randomBytes(12) + let sealed = nonce + (try Primitives.aesGCMSeal(key: key, nonce: nonce, ad: Array(name.utf8), plaintext: plain)) + var options: Data.WritingOptions = [.atomic] + #if os(iOS) + options.insert(.completeFileProtectionUntilFirstUserAuthentication) + #endif + try Data(sealed).write(to: url(name), options: options) + } catch { + // A cache that could not be written costs a slower next start. + } + } + + public func read(_ name: String) -> Bytes? { + guard let data = try? Data(contentsOf: url(name)), data.count > 28 else { return nil } + let bytes = data.byteArray + return try? Primitives.aesGCMOpen(key: key, nonce: Array(bytes[0..<12]), ad: Array(name.utf8), ciphertext: Array(bytes[12...])) + } + + public func remove(_ name: String) { + try? FileManager.default.removeItem(at: url(name)) + } + + /// Everything, and the key with it: a phone handed on must not paint the + /// previous household's home. + public func clear() { + try? FileManager.default.removeItem(at: directory) + store.remove(Self.keyName) + } + + public func writeJSON(_ name: String, _ value: T) { + guard let data = try? JSONEncoder().encode(value) else { return } + write(name, data.byteArray) + } + + public func readJSON(_ type: T.Type, _ name: String) -> T? { + guard let raw = read(name) else { return nil } + return try? JSONDecoder().decode(T.self, from: Data(raw)) + } +} + +/// Closed history tiles, per home, so opening the chart paints at once and +/// the box sends only what changed. +@MainActor +public final class TileCache { + struct Tile: Codable { + var tileId: String + var etag: String + var res: String + var startMs: Double + var stepMs: Double + var series: [String] + var data: Data + } + + private let files: SealedFiles? + private let siteId: String + private var tiles: [String: Tile] + private var dirty = false + + public init(files: SealedFiles?, siteId: String) { + self.files = files + self.siteId = siteId + tiles = files?.readJSON([String: Tile].self, "tiles-\(siteId)") ?? [:] + } + + public func get(_ ids: [String]) -> [String: HistChunk] { + var out = [String: HistChunk]() + for id in ids { + guard let t = tiles[id] else { continue } + out[id] = HistChunk(tileId: t.tileId, etag: t.etag, res: Resolution(rawValue: t.res) ?? .fiveMinutes, startMs: t.startMs, stepMs: t.stepMs, series: t.series, data: t.data.byteArray, partial: false) + } + return out + } + + /// The trailing tile is still filling and never cached. + public func put(_ chunk: HistChunk) { + guard !chunk.partial else { return } + tiles[chunk.tileId] = Tile(tileId: chunk.tileId, etag: chunk.etag, res: chunk.res.rawValue, startMs: chunk.startMs, stepMs: chunk.stepMs, series: chunk.series, data: Data(chunk.data)) + dirty = true + } + + /// Drop tiles past each resolution's retention, then write. + public func flush(nowMs: Double) { + let before = tiles.count + tiles = tiles.filter { _, t in + let res = Resolution(rawValue: t.res) ?? .fiveMinutes + return t.startMs + HistoryGeometry.spec(res).tileSpanMs >= nowMs - HistoryGeometry.spec(res).retentionMs + } + if dirty || tiles.count != before { + files?.writeJSON("tiles-\(siteId)", tiles) + dirty = false + } + } +} diff --git a/appleApp/FTWKit/Sources/FTWKit/Store/SecureStore.swift b/appleApp/FTWKit/Sources/FTWKit/Store/SecureStore.swift new file mode 100644 index 0000000..c43f86a --- /dev/null +++ b/appleApp/FTWKit/Sources/FTWKit/Store/SecureStore.swift @@ -0,0 +1,40 @@ +import Foundation + +/// Small secrets and records, by name. +/// +/// On Apple platforms this is the Keychain, readable after first unlock and +/// never synced or migrated to another device. Tests use `MemoryStore`. +/// +/// Everything here is a cache of what the box holds, except the device key, +/// which is this phone's identity. Clearing the store is signing out. +@MainActor +public protocol SecureStore: AnyObject { + func get(_ key: String) -> Bytes? + func put(_ key: String, _ value: Bytes) + func remove(_ key: String) +} + +@MainActor +public final class MemoryStore: SecureStore { + private var map: [String: Bytes] = [:] + + public init() {} + + public func get(_ key: String) -> Bytes? { map[key] } + public func put(_ key: String, _ value: Bytes) { map[key] = value } + public func remove(_ key: String) { map[key] = nil } + + public var keys: [String] { map.keys.sorted() } +} + +extension SecureStore { + func getJSON(_ type: T.Type, _ key: String) -> T? { + guard let raw = get(key) else { return nil } + return try? JSONDecoder().decode(T.self, from: Data(raw)) + } + + func putJSON(_ key: String, _ value: T) { + guard let data = try? JSONEncoder().encode(value) else { return } + put(key, data.byteArray) + } +} diff --git a/appleApp/FTWKit/Sources/FTWKit/Store/Sites.swift b/appleApp/FTWKit/Sources/FTWKit/Store/Sites.swift new file mode 100644 index 0000000..c8e63fc --- /dev/null +++ b/appleApp/FTWKit/Sources/FTWKit/Store/Sites.swift @@ -0,0 +1,99 @@ +import Foundation + +/// One paired home, as this phone remembers it. +public struct StoredSite: Codable, Equatable, Sendable, Identifiable { + public var siteId: String + public var label: String + /// Pinned optically. What stops the relay impersonating a box. + public var boxStaticKey: Data + /// Long-lived. The rotating relay handle is derived from it and only it. + /// Nil for a home paired before the v2 payload, which cannot be reached. + public var rendezvousSecret: Data? + /// Single use, spent in the first handshake. Kept until then. + public var pairingCode: Data? + public var lanHint: String? + /// Whether this household asked Sourceful to hold a sealed copy of it. + public var escrow: Bool + public var addedAtMs: Double + public var lastSeenAtMs: Double + + public var id: String { siteId } + + public init(siteId: String, label: String, boxStaticKey: Data, rendezvousSecret: Data?, pairingCode: Data?, lanHint: String?, escrow: Bool, addedAtMs: Double, lastSeenAtMs: Double) { + self.siteId = siteId + self.label = label + self.boxStaticKey = boxStaticKey + self.rendezvousSecret = rendezvousSecret + self.pairingCode = pairingCode + self.lanHint = lanHint + self.escrow = escrow + self.addedAtMs = addedAtMs + self.lastSeenAtMs = lastSeenAtMs + } +} + +/// The homes this phone is paired to, and which one it opens. +/// +/// Storing a home is a fact; making it the one the app shows is a decision, +/// so the two are separate calls. +@MainActor +public final class SiteList { + private let store: SecureStore + static let sitesKey = "sites" + static let currentKey = "current-site" + + public init(store: SecureStore) { + self.store = store + } + + public func all() -> [StoredSite] { + store.getJSON([StoredSite].self, Self.sitesKey) ?? [] + } + + public func get(_ siteId: String) -> StoredSite? { + all().first { $0.siteId == siteId } + } + + public func put(_ site: StoredSite) { + var rows = all().filter { $0.siteId != site.siteId } + rows.append(site) + store.putJSON(Self.sitesKey, rows) + } + + public func update(_ siteId: String, _ change: (inout StoredSite) -> Void) { + guard var row = get(siteId) else { return } + change(&row) + put(row) + } + + /// The home the app opens: the pointer, else the first row. A pointer + /// left behind after its row is gone is ignored rather than trusted. + public func current() -> StoredSite? { + let rows = all() + if let id = store.get(Self.currentKey).map({ String(decoding: $0, as: UTF8.self) }), + let row = rows.first(where: { $0.siteId == id }) { + return row + } + return rows.first + } + + public func setCurrent(_ siteId: String) { + store.put(Self.currentKey, Array(siteId.utf8)) + } + + public func clear() { + store.remove(Self.sitesKey) + store.remove(Self.currentKey) + } + + /// Six hex characters of the box key's digest, so two boxes look + /// different. The same name the web app shows for the same box. + public nonisolated static func fingerprint(_ boxStaticKey: Bytes) -> String { + Array(Primitives.sha256(boxStaticKey).prefix(3)).hex.uppercased() + } + + /// A local id for a box, never sent anywhere. + public nonisolated static func siteID(_ boxStaticKey: Bytes) -> String { + Array(Primitives.sha256(boxStaticKey).prefix(8)).hex + } +} diff --git a/appleApp/FTWKit/Sources/FTWKit/Support/Base64url.swift b/appleApp/FTWKit/Sources/FTWKit/Support/Base64url.swift new file mode 100644 index 0000000..33fd967 --- /dev/null +++ b/appleApp/FTWKit/Sources/FTWKit/Support/Base64url.swift @@ -0,0 +1,84 @@ +import Foundation + +/// Unpadded base64url, strict on the way in. +/// +/// Strict because every value decoded here came off a QR code or a box, and +/// a lenient decoder turns a misread character into a different key rather +/// than into an error anyone would see. +public enum Base64url { + public struct DecodeError: Error, Equatable { + public let message: String + } + + private static let alphabet = Array("ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_".utf8) + + private static let lookup: [Int8] = { + var table = [Int8](repeating: -1, count: 256) + for (i, c) in alphabet.enumerated() { table[Int(c)] = Int8(i) } + return table + }() + + public static func encode(_ bytes: Bytes) -> String { + var out = [UInt8]() + out.reserveCapacity((bytes.count * 4 + 2) / 3) + var i = 0 + while i + 3 <= bytes.count { + let n = UInt32(bytes[i]) << 16 | UInt32(bytes[i + 1]) << 8 | UInt32(bytes[i + 2]) + out.append(alphabet[Int(n >> 18 & 63)]) + out.append(alphabet[Int(n >> 12 & 63)]) + out.append(alphabet[Int(n >> 6 & 63)]) + out.append(alphabet[Int(n & 63)]) + i += 3 + } + let rest = bytes.count - i + if rest == 1 { + let n = UInt32(bytes[i]) << 16 + out.append(alphabet[Int(n >> 18 & 63)]) + out.append(alphabet[Int(n >> 12 & 63)]) + } else if rest == 2 { + let n = UInt32(bytes[i]) << 16 | UInt32(bytes[i + 1]) << 8 + out.append(alphabet[Int(n >> 18 & 63)]) + out.append(alphabet[Int(n >> 12 & 63)]) + out.append(alphabet[Int(n >> 6 & 63)]) + } + return String(decoding: out, as: UTF8.self) + } + + public static func decode(_ text: String) throws -> Bytes { + let chars = Array(text.utf8) + if chars.count % 4 == 1 { + throw DecodeError(message: "length \(chars.count) is not a base64url length") + } + var values = [UInt8]() + values.reserveCapacity(chars.count) + for c in chars { + let v = lookup[Int(c)] + if v < 0 { throw DecodeError(message: "character \(Character(UnicodeScalar(c))) is not base64url") } + values.append(UInt8(v)) + } + var out = Bytes() + out.reserveCapacity(values.count * 3 / 4) + var i = 0 + while i + 4 <= values.count { + let n = UInt32(values[i]) << 18 | UInt32(values[i + 1]) << 12 | UInt32(values[i + 2]) << 6 | UInt32(values[i + 3]) + out.append(UInt8(n >> 16 & 0xff)) + out.append(UInt8(n >> 8 & 0xff)) + out.append(UInt8(n & 0xff)) + i += 4 + } + let rest = values.count - i + if rest == 2 { + let n = UInt32(values[i]) << 18 | UInt32(values[i + 1]) << 12 + // Bits past the last whole byte must be zero, or two strings + // decode to the same bytes and the encoding stops being unique. + if n & 0xffff != 0 { throw DecodeError(message: "trailing bits are not zero") } + out.append(UInt8(n >> 16 & 0xff)) + } else if rest == 3 { + let n = UInt32(values[i]) << 18 | UInt32(values[i + 1]) << 12 | UInt32(values[i + 2]) << 6 + if n & 0xff != 0 { throw DecodeError(message: "trailing bits are not zero") } + out.append(UInt8(n >> 16 & 0xff)) + out.append(UInt8(n >> 8 & 0xff)) + } + return out + } +} diff --git a/appleApp/FTWKit/Sources/FTWKit/Support/Bytes.swift b/appleApp/FTWKit/Sources/FTWKit/Support/Bytes.swift new file mode 100644 index 0000000..08bbc08 --- /dev/null +++ b/appleApp/FTWKit/Sources/FTWKit/Support/Bytes.swift @@ -0,0 +1,82 @@ +import Foundation + +/// Raw bytes. `[UInt8]` rather than `Data` inside the protocol, because +/// indexing a `Data` slice by absolute offset is the classic way to read the +/// wrong byte, and every layer here indexes. +public typealias Bytes = [UInt8] + +extension Array where Element == UInt8 { + public init(hex: String) { + var out = Bytes() + out.reserveCapacity(hex.count / 2) + var high: UInt8? + for ch in hex.utf8 { + let v: UInt8 + switch ch { + case 0x30...0x39: v = ch - 0x30 + case 0x61...0x66: v = ch - 0x61 + 10 + case 0x41...0x46: v = ch - 0x41 + 10 + default: continue + } + if let h = high { + out.append(h << 4 | v) + high = nil + } else { + high = v + } + } + self = out + } + + public var hex: String { + let digits = Array("0123456789abcdef".utf8) + var out = [UInt8]() + out.reserveCapacity(count * 2) + for b in self { + out.append(digits[Int(b >> 4)]) + out.append(digits[Int(b & 0x0f)]) + } + return String(decoding: out, as: UTF8.self) + } + + public var data: Data { Data(self) } +} + +extension Data { + /// Not `bytes`: newer Foundation already has a `Data.bytes` span. + public var byteArray: Bytes { Bytes(self) } +} + +/// Concatenate byte runs without an intermediate array per step. +public func concat(_ parts: Bytes...) -> Bytes { + var out = Bytes() + out.reserveCapacity(parts.reduce(0) { $0 + $1.count }) + for p in parts { out.append(contentsOf: p) } + return out +} + +/// Cryptographically random bytes from the system generator. +public func randomBytes(_ count: Int) -> Bytes { + var rng = SystemRandomNumberGenerator() + return (0.. Bool { + guard a.count == b.count else { return false } + var diff: UInt8 = 0 + for i in a.indices { diff |= a[i] ^ b[i] } + return diff == 0 +} + +extension UInt64 { + var bigEndianBytes: Bytes { + (0..<8).map { UInt8(truncatingIfNeeded: self >> (8 * (7 - $0))) } + } +} diff --git a/appleApp/FTWKit/Sources/FTWKit/Support/JSON.swift b/appleApp/FTWKit/Sources/FTWKit/Support/JSON.swift new file mode 100644 index 0000000..c7b851d --- /dev/null +++ b/appleApp/FTWKit/Sources/FTWKit/Support/JSON.swift @@ -0,0 +1,233 @@ +import Foundation + +/// A JSON value, read tolerantly. The box's HTTP answers are read the way the +/// web app reads them: a missing or mistyped field becomes nil, never a +/// crash, so a newer box's extra fields and an older box's missing ones are +/// both ordinary. +/// +/// Its own small parser rather than JSONSerialization, which reports +/// booleans as numbers differently on each platform. +public enum JSON: Equatable, Sendable { + case null + case bool(Bool) + case number(Double) + case string(String) + case array([JSON]) + /// Keys in document order, so a body re-encodes as it came. + case object([(String, JSON)]) + + public static func == (a: JSON, b: JSON) -> Bool { + switch (a, b) { + case (.null, .null): return true + case let (.bool(x), .bool(y)): return x == y + case let (.number(x), .number(y)): return x == y + case let (.string(x), .string(y)): return x == y + case let (.array(x), .array(y)): return x == y + case let (.object(x), .object(y)): + return x.count == y.count && zip(x, y).allSatisfy { $0.0 == $1.0 && $0.1 == $1.1 } + default: return false + } + } + + public struct ParseError: Error, Equatable { + public let offset: Int + } + + public init(parsing bytes: Bytes) throws { + var p = Parser(b: bytes) + p.skip() + self = try p.value(depth: 0) + p.skip() + guard p.i == bytes.count else { throw ParseError(offset: p.i) } + } + + public subscript(key: String) -> JSON? { + if case .object(let o) = self { return o.first { $0.0 == key }?.1 } + return nil + } + + public subscript(index: Int) -> JSON? { + if case .array(let a) = self, a.indices.contains(index) { return a[index] } + return nil + } + + /// A finite number, or nil. + public var number: Double? { + if case .number(let n) = self, n.isFinite { return n } + return nil + } + + public var string: String? { + if case .string(let s) = self { return s } + return nil + } + + public var bool: Bool? { + if case .bool(let b) = self { return b } + return nil + } + + public var array: [JSON]? { + if case .array(let a) = self { return a } + return nil + } + + public var object: [(String, JSON)]? { + if case .object(let o) = self { return o } + return nil + } + + public var isNull: Bool { self == .null } + + public func encoded() -> Bytes { Array(text.utf8) } + + public var text: String { + switch self { + case .null: return "null" + case .bool(let b): return b ? "true" : "false" + case .number(let n): + guard n.isFinite else { return "null" } + if n.rounded() == n, abs(n) < 9_007_199_254_740_992 { return String(Int64(n)) } + return "\(n)" + case .string(let s): return jsonString(s) + case .array(let a): return "[" + a.map(\.text).joined(separator: ",") + "]" + case .object(let o): return "{" + o.map { jsonString($0.0) + ":" + $0.1.text }.joined(separator: ",") + "}" + } + } + + /// A copy with one key set, keeping the others and their order. + public func setting(_ key: String, _ value: JSON) -> JSON { + guard case .object(var o) = self else { return self } + if let i = o.firstIndex(where: { $0.0 == key }) { + o[i].1 = value + } else { + o.append((key, value)) + } + return .object(o) + } + + private struct Parser { + let b: Bytes + var i = 0 + + mutating func skip() { + while i < b.count, b[i] == 0x20 || b[i] == 0x0a || b[i] == 0x0d || b[i] == 0x09 { i += 1 } + } + + mutating func expect(_ c: UInt8) throws { + guard i < b.count, b[i] == c else { throw ParseError(offset: i) } + i += 1 + } + + mutating func literal(_ word: String) throws { + for c in word.utf8 { try expect(c) } + } + + mutating func value(depth: Int) throws -> JSON { + guard depth < 128, i < b.count else { throw ParseError(offset: i) } + switch b[i] { + case UInt8(ascii: "{"): + i += 1 + var out = [(String, JSON)]() + skip() + if i < b.count, b[i] == UInt8(ascii: "}") { i += 1; return .object(out) } + while true { + skip() + let k = try string() + skip() + try expect(UInt8(ascii: ":")) + skip() + let v = try value(depth: depth + 1) + out.append((k, v)) + skip() + guard i < b.count else { throw ParseError(offset: i) } + if b[i] == UInt8(ascii: ",") { i += 1; continue } + try expect(UInt8(ascii: "}")) + return .object(out) + } + case UInt8(ascii: "["): + i += 1 + var out = [JSON]() + skip() + if i < b.count, b[i] == UInt8(ascii: "]") { i += 1; return .array(out) } + while true { + skip() + out.append(try value(depth: depth + 1)) + skip() + guard i < b.count else { throw ParseError(offset: i) } + if b[i] == UInt8(ascii: ",") { i += 1; continue } + try expect(UInt8(ascii: "]")) + return .array(out) + } + case UInt8(ascii: "\""): + return .string(try string()) + case UInt8(ascii: "t"): + try literal("true") + return .bool(true) + case UInt8(ascii: "f"): + try literal("false") + return .bool(false) + case UInt8(ascii: "n"): + try literal("null") + return .null + default: + return .number(try number()) + } + } + + mutating func number() throws -> Double { + let start = i + while i < b.count, "+-0123456789.eE".utf8.contains(b[i]) { i += 1 } + guard i > start, let n = Double(String(decoding: b[start.. UInt32 { + guard i + 4 <= b.count, let v = UInt32(String(decoding: b[i.. String { + try expect(UInt8(ascii: "\"")) + var out = Bytes() + while true { + guard i < b.count else { throw ParseError(offset: i) } + let c = b[i] + i += 1 + if c == UInt8(ascii: "\"") { break } + if c != UInt8(ascii: "\\") { out.append(c); continue } + guard i < b.count else { throw ParseError(offset: i) } + let e = b[i] + i += 1 + switch e { + case UInt8(ascii: "n"): out.append(0x0a) + case UInt8(ascii: "t"): out.append(0x09) + case UInt8(ascii: "r"): out.append(0x0d) + case UInt8(ascii: "b"): out.append(0x08) + case UInt8(ascii: "f"): out.append(0x0c) + case UInt8(ascii: "u"): + var scalar = try hex4() + if scalar >= 0xd800, scalar < 0xdc00, i + 6 <= b.count, b[i] == UInt8(ascii: "\\"), b[i + 1] == UInt8(ascii: "u") { + i += 2 + let low = try hex4() + scalar = 0x10000 + ((scalar - 0xd800) << 10) + (low - 0xdc00) + } + out += Array(String(Character(UnicodeScalar(scalar) ?? "\u{fffd}")).utf8) + default: out.append(e) + } + } + return String(decoding: out, as: UTF8.self) + } + } +} + +extension JSON: ExpressibleByDictionaryLiteral, ExpressibleByArrayLiteral, ExpressibleByStringLiteral, ExpressibleByFloatLiteral, ExpressibleByIntegerLiteral, ExpressibleByBooleanLiteral, ExpressibleByNilLiteral { + public init(dictionaryLiteral elements: (String, JSON)...) { self = .object(elements) } + public init(arrayLiteral elements: JSON...) { self = .array(elements) } + public init(stringLiteral value: String) { self = .string(value) } + public init(floatLiteral value: Double) { self = .number(value) } + public init(integerLiteral value: Int) { self = .number(Double(value)) } + public init(booleanLiteral value: Bool) { self = .bool(value) } + public init(nilLiteral: ()) { self = .null } +} diff --git a/appleApp/FTWKit/Sources/FTWKit/Support/Scheduler.swift b/appleApp/FTWKit/Sources/FTWKit/Support/Scheduler.swift new file mode 100644 index 0000000..b328842 --- /dev/null +++ b/appleApp/FTWKit/Sources/FTWKit/Support/Scheduler.swift @@ -0,0 +1,103 @@ +import Foundation + +/// Time, as everything above the wire sees it: a wall clock and timers. +/// +/// Injected so the carrier backoffs, the session's deadlines and every +/// retry can be tested by moving a clock rather than by waiting. The app +/// uses `LiveScheduler`; tests use `ManualScheduler`. +@MainActor +public protocol Scheduler: AnyObject { + /// Wall clock, in milliseconds since 1970. + var nowMs: Double { get } + /// Run `action` once after `ms`. Cancelling is always safe, even after + /// it has run. + @discardableResult + func after(_ ms: Double, _ action: @escaping @MainActor () -> Void) -> Cancellable + /// A number in [0, 1), for jitter. + func random() -> Double +} + +@MainActor +public final class Cancellable { + private var cancelled = false + private let onCancel: (() -> Void)? + + init(onCancel: (() -> Void)? = nil) { + self.onCancel = onCancel + } + + public var isCancelled: Bool { cancelled } + + public func cancel() { + guard !cancelled else { return } + cancelled = true + onCancel?() + } +} + +@MainActor +public final class LiveScheduler: Scheduler { + public static let shared = LiveScheduler() + + public init() {} + + public var nowMs: Double { Date().timeIntervalSince1970 * 1000 } + + public func after(_ ms: Double, _ action: @escaping @MainActor () -> Void) -> Cancellable { + let task = Task { @MainActor in + try? await Task.sleep(nanoseconds: UInt64(max(0, ms) * 1_000_000)) + if Task.isCancelled { return } + action() + } + let token = Cancellable { task.cancel() } + return token + } + + public func random() -> Double { Double.random(in: 0..<1) } +} + +/// A clock that only moves when a test says so. +@MainActor +public final class ManualScheduler: Scheduler { + private struct Pending { + let at: Double + let seq: Int + let token: Cancellable + let action: @MainActor () -> Void + } + + public private(set) var nowMs: Double + private var pending: [Pending] = [] + private var seq = 0 + public var randomValue: Double = 0.5 + + public init(nowMs: Double = 1_750_000_000_000) { + self.nowMs = nowMs + } + + public func after(_ ms: Double, _ action: @escaping @MainActor () -> Void) -> Cancellable { + let token = Cancellable() + seq += 1 + pending.append(Pending(at: nowMs + max(0, ms), seq: seq, token: token, action: action)) + return token + } + + public func random() -> Double { randomValue } + + /// Move time forward, running every timer that falls due, in order. + public func advance(_ ms: Double) { + let target = nowMs + ms + while true { + pending.removeAll { $0.token.isCancelled } + guard let next = pending.filter({ $0.at <= target }).min(by: { ($0.at, $0.seq) < ($1.at, $1.seq) }) else { break } + pending.removeAll { $0.seq == next.seq } + nowMs = max(nowMs, next.at) + next.action() + } + nowMs = target + } + + public var pendingCount: Int { + pending.filter { !$0.token.isCancelled }.count + } +} diff --git a/appleApp/FTWKit/Tests/FTWKitTests/AppModelTests.swift b/appleApp/FTWKit/Tests/FTWKitTests/AppModelTests.swift new file mode 100644 index 0000000..e176f19 --- /dev/null +++ b/appleApp/FTWKit/Tests/FTWKitTests/AppModelTests.swift @@ -0,0 +1,331 @@ +import Foundation +import Testing +@testable import FTWKit + +@MainActor +@Suite struct DemoHomeTests { + /// The demo: every screen's model against the simulated box, with no + /// passkey, no relay and nothing written. + func demo() async -> (AppModel, HomeModels, ManualScheduler) { + let scheduler = ManualScheduler() + let app = AppModel(store: MemoryStore(), files: nil, passkeys: nil, scheduler: scheduler, build: "test", ua: "test") + app.startDemo() + let home = app.home! + await run(scheduler, 1_000) + return (app, home, scheduler) + } + + func run(_ s: ManualScheduler, _ ms: Double, step: Double = 50) async { + var left = ms + while left > 0 { + for _ in 0..<10 { await Task.yield() } + s.advance(min(step, left)) + left -= step + } + for _ in 0..<10 { await Task.yield() } + } + + @Test func theDemoStreamsAndSaysItIsLive() async { + let (app, home, s) = await demo() + #expect(app.isDemo) + #expect(home.site.session.phase == .streaming) + await run(s, 2_000) + #expect(home.site.carrier == .relay) + #expect(home.site.isLive) + #expect(home.site.explanation.situation != .noData) + #expect(home.site.recentField(Contract.FID.gridW).count >= 2) + } + + @Test func thePlanLoadsAndAModeChangeFollowsIt() async { + let (_, home, s) = await demo() + home.plan.activate() + await run(s, 500) + #expect(home.plan.plan?.slots.count == 96) + #expect(home.plan.canControl) + #expect(home.plan.primaryModes.map(\.key) == ["planner_passive_arbitrage", "planner_arbitrage"]) + #expect(home.plan.actualMode == "planner_passive_arbitrage") + let task = Task { await home.plan.setMode("self_consumption") } + await run(s, 500) + await task.value + #expect(home.plan.command == .applied("self_consumption")) + await run(s, 1_500) + #expect(home.plan.actualMode == "self_consumption") + #expect(home.plan.inManual) + await run(s, PlanModel.settleMs + 100) + #expect(home.plan.command == .idle) + } + + @Test func pricesEnergyHistoryAndSavingsFill() async { + let (_, home, s) = await demo() + home.prices.activate() + home.energy.activate() + home.history.activate() + home.savings.activate() + home.status.activate() + await run(s, 3_000) + #expect(home.prices.prices?.currency == "SEK") + #expect(home.energy.loaded) + #expect(home.energy.days.count == 7) + #expect(home.energy.totals.loadWh > 0) + home.energy.select(.month) + #expect(!home.energy.loaded) + await run(s, 1_000) + #expect(home.energy.days.count == 30) + #expect(home.history.loaded) + #expect((home.history.frame?.points ?? 0) > 200) + #expect(home.savings.periods?.week.available == true) + #expect(home.status.fresh) + #expect(Flow.fuse(status: home.status.status!) != nil) + } + + @Test func theChargerSheetSendsIntentAndRereads() async throws { + let (_, home, s) = await demo() + home.loadpoints.activate() + await run(s, 1_000) + let lp = try #require(home.loadpoints.points.first) + #expect(lp.pluggedIn) + #expect(home.loadpoints.windows[lp.id] != nil) + + let hold = Task { await home.loadpoints.chargeNow(lp, amps: 10) } + await run(s, 1_000) + await hold.value + #expect(home.loadpoints.points.first?.manualActive == true) + + let release = Task { await home.loadpoints.stopCharging(lp) } + await run(s, 1_000) + await release.value + #expect(home.loadpoints.outcome(for: .hold, lp) == "The plan decides when to charge.") + + let soc = Task { await home.loadpoints.setSoc(lp, pct: 55) } + await run(s, 1_000) + await soc.value + #expect(home.loadpoints.points.first?.socPct == 55) + + let save = Task { try await home.loadpoints.saveSchedule(lp, socPct: 90, hour: 6, minute: 30, recurring: true, days: 0b0011111, surplusUnlockPct: 0) } + await run(s, 1_000) + try await save.value + #expect(home.loadpoints.points.first?.schedule?.socPct == 90) + } + + @Test func theBoxScreenReadsTheRosterAndInvitesAViewer() async { + let (_, home, s) = await demo() + home.access.activate() + home.notify.activate() + await run(s, 1_000) + #expect(home.access.loaded) + #expect(home.access.members.first?.isThisPhone == true) + let invite = Task { await home.access.inviteViewer() } + await run(s, 500) + #expect(await invite.value) + #expect(home.access.invite?.role == Contract.roleViewer) + #expect(home.notify.availableKinds.count == 6) + let save = Task { await home.notify.save(["charging.connected": true]) } + await run(s, 500) + #expect(await save.value) + #expect(home.notify.rules["charging.connected"] == true) + #expect(home.notify.boxEnabled) + } + + @Test func leavingTheDemoTouchesNoSavedHome() async { + let (app, _, _) = await demo() + app.exitDemo() + #expect(app.home == nil) + #expect(!app.isDemo) + } +} + +@MainActor +@Suite struct ShellTests { + @MainActor + struct World { + let scheduler = ManualScheduler() + let store = MemoryStore() + let files: SealedFiles + let box: SimulatedBox + let endpoint: NoiseBoxEndpoint + let relay: FakeRelay + let escrow = FakeEscrowService() + let passkeys = FakePasskeys() + + init() { + files = SealedFiles(directory: URL(fileURLWithPath: NSTemporaryDirectory()).appendingPathComponent("ftw-test-\(UUID().uuidString)"), store: store) + box = SimulatedBox(scheduler: scheduler) + endpoint = NoiseBoxEndpoint(box: box) + relay = FakeRelay(scheduler: scheduler, endpoint: endpoint) + } + + func app() -> AppModel { + AppModel(store: store, files: files, passkeys: passkeys, scheduler: scheduler, build: "test", ua: "test", relayURL: URL(string: "wss://relay.test")!, escrowTransport: escrow.transport, makeSocket: relay.factory) + } + + var pairingURL: String { + Enrollment(boxStaticPublic: endpoint.staticKey.publicKey, pairingCode: randomBytes(16), lanHint: "", rendezvousSecret: randomBytes(32)).url() + } + + func run(_ ms: Double, step: Double = 20) async { + var left = ms + while left > 0 { + for _ in 0..<10 { await Task.yield() } + scheduler.advance(min(step, left)) + left -= step + } + for _ in 0..<10 { await Task.yield() } + } + } + + @Test func pairStreamCloseAndReopenFromCache() async throws { + let world = World() + let app = world.app() + app.launch() + #expect(app.home == nil) + + let pair = PairModel(app: app) + let task = Task { await pair.pair(world.pairingURL) } + await world.run(500) + await task.value + let site = try #require(app.site) + #expect(site.session.phase == .streaming) + #expect(site.carrier == .relay) + #expect(world.passkeys.registrations == 1) + // Held a sealed copy in the background. + await world.run(200) + #expect(world.escrow.rows.count == 1) + + // Long enough for the snapshot to be written. + await world.run(SiteModel.snapshotIntervalMs + 500, step: 250) + site.persistNow() + + // A cold start paints the cache before any socket opens, and never + // asks for a passkey to read. + let again = world.app() + again.launch() + let cold = try #require(again.site) + #expect(cold.carrier == .cache) + #expect(cold.session.fields[Contract.FID.gridW] != nil) + #expect(cold.srcState == .stale) + #expect(cold.ageMs != nil) + await world.run(500) + #expect(cold.session.phase == .streaming) + #expect(cold.carrier == .relay) + #expect(world.passkeys.assertions.isEmpty) + } + + @Test func aLinkToTheSameBoxIsALeftoverNotAnInvitation() async throws { + let world = World() + let app = world.app() + let url = world.pairingURL + let pair = PairModel(app: app) + let task = Task { await pair.pair(url) } + await world.run(500) + await task.value + app.offer(url) + #expect(app.offeredLink == nil) + let other = Enrollment(boxStaticPublic: Primitives.generateKeyPair().publicKey, pairingCode: randomBytes(16), lanHint: "", rendezvousSecret: randomBytes(32)).url() + app.offer(other) + #expect(app.offeredLink == other) + #expect(PairModel(app: app).offeredFingerprint != nil) + } + + @Test func aConfigureWriteRunsTheCeremonyOnce() async throws { + let world = World() + let app = world.app() + let pair = PairModel(app: app) + let t = Task { await pair.pair(world.pairingURL) } + await world.run(500) + await t.value + // The simulated box refuses configure writes without the flag. + let strict = SimulatedBox(scheduler: world.scheduler, requireStepUp: true) + _ = strict + let site = try #require(app.site) + let restart = RestartModel(site: site) + let task = Task { await restart.restart() } + await world.run(300) + await task.value + #expect(restart.error == nil) + } + + @Test func noKeyIsSaidPlainlyAndNothingRetries() async throws { + let world = World() + let app = world.app() + let pair = PairModel(app: app) + let t = Task { await pair.pair(world.pairingURL) } + await world.run(500) + await t.value + // The identity is gone while the home's row survives. + app.vault.reset() + let again = world.app() + again.launch() + await world.run(100) + #expect(again.connectHelp == "This device has no key for that home.") + } + + @Test func signingOutClearsTheKeyTheHomeAndTheCache() async throws { + let world = World() + let app = world.app() + let pair = PairModel(app: app) + let t = Task { await pair.pair(world.pairingURL) } + await world.run(500) + await t.value + app.site?.persistNow() + app.leave() + #expect(app.home == nil) + #expect(!app.vault.isEnrolled) + #expect(app.sites.all().isEmpty) + #expect(world.store.keys.isEmpty) + // The sealed copy stays: removing it is its own act. + #expect(world.escrow.rows.count == 1) + } +} + +@MainActor +@Suite struct SealedCopyTests { + @Test func theSwitchFollowsWhatTheEscrowHolds() async throws { + let world = ShellTests.World() + let app = world.app() + let pair = PairModel(app: app) + let t = Task { await pair.pair(world.pairingURL) } + await world.run(500) + await t.value + await world.run(200) + let copy = try #require(app.sealedCopy) + // Pairing sealed a copy in the background; the screen reads it again. + copy.reload() + #expect(copy.kept) + #expect(world.escrow.rows.values.first?.blob.isEmpty == false) + + let off = Task { await copy.set(false) } + await world.run(200) + await off.value + #expect(!copy.kept) + #expect(copy.stage == .idle) + #expect(app.sites.all().first?.escrow == false) + #expect(world.escrow.rows.values.first?.blob.isEmpty == true) + + let on = Task { await copy.set(true) } + await world.run(200) + await on.value + #expect(copy.kept) + #expect(app.sites.all().first?.escrow == true) + #expect(world.escrow.rows.values.first?.blob.isEmpty == false) + } + + @Test func aDeclinedPasskeyPutsTheMarkBack() async throws { + let world = ShellTests.World() + let app = world.app() + let pair = PairModel(app: app) + let t = Task { await pair.pair(world.pairingURL) } + await world.run(500) + await t.value + await world.run(200) + let copy = try #require(app.sealedCopy) + copy.reload() + world.passkeys.cancel = true + let off = Task { await copy.set(false) } + await world.run(200) + await off.value + #expect(copy.kept) + #expect(copy.stage == .idle) + #expect(copy.problem == nil) + #expect(app.sites.all().first?.escrow == true) + } +} diff --git a/appleApp/FTWKit/Tests/FTWKitTests/Fakes.swift b/appleApp/FTWKit/Tests/FTWKitTests/Fakes.swift new file mode 100644 index 0000000..bdbee2d --- /dev/null +++ b/appleApp/FTWKit/Tests/FTWKitTests/Fakes.swift @@ -0,0 +1,83 @@ +import Foundation +@testable import FTWKit + +/// A passkey that always answers with the same PRF output, the way one +/// synced passkey answers on every device. +@MainActor +final class FakePasskeys: PasskeyAuthenticator { + var prf: Bytes? + var credentialID: String + var isAvailable = true + var cancel = false + var fail = false + var prfOnlyOnAssert = false + private(set) var registrations = 0 + private(set) var assertions: [[String]] = [] + + init(prf: Bytes? = Bytes(hex: "a0a1a2a3a4a5a6a7a8a9aaabacadaeafb0b1b2b3b4b5b6b7b8b9babbbcbdbebf"), credentialID: String = "Y3JlZC0x") { + self.prf = prf + self.credentialID = credentialID + } + + func register(label: String, userHandle: Bytes, excludeCredentialIDs: [String]) async throws -> PasskeyOutcome { + registrations += 1 + if cancel { throw PasskeyCancelled() } + if fail { throw Primitives.CryptoFailure(message: "platform failed") } + if prfOnlyOnAssert { return PasskeyOutcome(credentialID: credentialID, prfOutput: nil, prfEnabled: prf != nil) } + return PasskeyOutcome(credentialID: credentialID, prfOutput: prf) + } + + func assert(credentialIDs: [String]) async throws -> PasskeyOutcome { + assertions.append(credentialIDs) + if cancel { throw PasskeyCancelled() } + if fail { throw Primitives.CryptoFailure(message: "platform failed") } + return PasskeyOutcome(credentialID: credentialID, prfOutput: prf) + } +} + +/// The escrow service's rules, from escrow/src in the web app repository: +/// one length for every request, a signed write whose key is pinned on first +/// use, and a version that must be the immediate successor. +@MainActor +final class FakeEscrowService { + struct Row { + var version: UInt32 + var blob: Bytes + var writeKey: Bytes + } + + var rows: [String: Row] = [:] + var offline = false + private(set) var requests = 0 + + var transport: Escrow.Transport { + { [unowned self] body in try self.handle(body) } + } + + func handle(_ body: Bytes) throws -> (status: Int, body: Bytes) { + requests += 1 + if offline { throw URLError(.notConnectedToInternet) } + guard body.count == Escrow.requestBytes, + let json = try JSONSerialization.jsonObject(with: Data(body)) as? [String: Any], + let id = json["id"] as? String else { return (400, []) } + switch json["op"] as? String { + case "get": + guard let row = rows[id] else { return (404, []) } + let answer: [String: Any] = ["version": row.version, "blob": Data(row.blob).base64EncodedString()] + return (200, try JSONSerialization.data(withJSONObject: answer).byteArray) + case "put": + guard let version = (json["version"] as? NSNumber)?.uint32Value, + let blob = (json["blob"] as? String).flatMap({ Data(base64Encoded: $0) })?.byteArray, + blob.count == RecoveryBlob.maxBytes || blob.isEmpty, + let pub = (json["pub"] as? String).flatMap({ try? Base64url.decode($0) }), + let sig = (json["sig"] as? String).flatMap({ try? Base64url.decode($0) }) else { return (400, []) } + guard Primitives.ed25519Verify(publicKey: pub, signature: sig, message: Escrow.writeMessage(lookupID: id, version: version, blob: blob)) else { return (403, []) } + if let held = rows[id], held.writeKey != pub { return (403, []) } + guard version == (rows[id]?.version ?? 0) + 1 else { return (409, []) } + rows[id] = Row(version: version, blob: blob, writeKey: pub) + return (200, Array(#"{"version":\#(version)}"#.utf8)) + default: + return (400, []) + } + } +} diff --git a/appleApp/FTWKit/Tests/FTWKitTests/FormatTests.swift b/appleApp/FTWKit/Tests/FTWKitTests/FormatTests.swift new file mode 100644 index 0000000..4cd43f3 --- /dev/null +++ b/appleApp/FTWKit/Tests/FTWKitTests/FormatTests.swift @@ -0,0 +1,200 @@ +import Foundation +import Testing +@testable import FTWKit + +/// The sentences and figures the web app's own tests pin, so both apps say +/// the same thing about the same house. +@Suite struct FormatTests { + typealias F = Contract.FID + + @Test func powerNeverShowsARawMinus() { + for w in [-1_000_000.0, -4200, -60, 0, 60, 4200] { + #expect(!PowerFormat.parts(w).text.hasPrefix("-")) + } + #expect(PowerFormat.parts(4200).direction == .into) + #expect(PowerFormat.parts(-4200).direction == .out) + #expect(PowerFormat.direction(PowerFormat.noiseW - 1) == .idle) + #expect(PowerFormat.direction(-PowerFormat.noiseW) == .out) + #expect(PowerFormat.parts(9900).text == "9.9") + #expect(PowerFormat.parts(11_400).text == "11") + #expect(PowerFormat.scale(5000) == "5 kW") + #expect(PowerFormat.scale(1500) == "1.5 kW") + #expect(PowerFormat.scale(-5000) == PowerFormat.scale(5000)) + #expect(PowerFormat.scale(.nan) == "") + #expect(PowerFormat.soc(875) == "88") + #expect(PowerFormat.soc(.nan) == "—") + #expect(PowerFormat.age(0) == "just now") + #expect(PowerFormat.age(30_000) == "30s ago") + #expect(PowerFormat.age(90_000) == "1 min ago") + #expect(PowerFormat.age(3_600_000) == "1 h ago") + #expect(PowerFormat.age(nil) == "unknown") + } + + @Test func explanationsMatchTheWebApp() { + func explain(_ f: [Int: Double], blocked: [String] = [], ceiling: Double? = nil) -> Explanation.Result { + Explanation.explain(fields: f, dispatchBlockedBy: blocked, ceilingW: ceiling) + } + let shaving = explain([F.gridW: 11_000, F.pvW: 0, F.batteryW: -4200, F.loadW: 15_200], ceiling: 11_000) + #expect(shaving.situation == .batteryShaving) + #expect(shaving.headline == "The battery is supplying 4.2 kW to keep grid import below 11 kW.") + let covering = explain([F.gridW: 0, F.pvW: 0, F.batteryW: -2100, F.loadW: 2100]) + #expect(covering.headline == "The battery is covering the house, so nothing is coming from the grid.") + let importing = explain([F.gridW: 2400, F.pvW: 0, F.batteryW: 0, F.loadW: 2400]) + #expect(importing.headline == "The house is drawing 2.4 kW from the grid.") + #expect(explain([F.gridW: -3000, F.pvW: -5000, F.batteryW: 0, F.loadW: 2000]).situation == .exportingSurplus) + #expect(explain([F.gridW: 0, F.pvW: -5000, F.batteryW: 3000, F.loadW: 2000]).situation == .chargingFromSurplus) + #expect(explain([F.gridW: 10, F.pvW: -2000, F.batteryW: 0, F.loadW: 2000]).situation == .solarCovering) + #expect(explain([F.gridW: 800, F.pvW: -1200, F.batteryW: 0, F.loadW: 2000]).situation == .solarPartial) + #expect(explain([F.gridW: 2400, F.loadW: 2400], blocked: ["meter"]).situation == .dispatchBlocked) + #expect(explain([:]).situation == .noData) + #expect(!explain([F.gridW: -3000, F.pvW: -5000, F.batteryW: -100, F.loadW: 2000]).headline.contains("-")) + } + + @Test func planHeadlineNamesTheNextChange() { + let now = 1_750_000_000_000.0 + let slots = [ + PlanSlot(startMs: now - 60_000, durationMs: 900_000, batteryW: 3000, gridW: 3000, priceMinor: 30, reason: "cheap_import"), + PlanSlot(startMs: now + 840_000, durationMs: 900_000, batteryW: 3000, gridW: 3000, priceMinor: 30, reason: "cheap_import"), + PlanSlot(startMs: now + 1_740_000, durationMs: 900_000, batteryW: -2000, gridW: 0, priceMinor: 150, reason: "expensive_import"), + ] + let h = PlanText.headline(Plan(rev: 1, uptimeMs: 0, slots: slots, stale: false, ceilingW: nil), nowMs: now) + #expect(h.text == "The battery is charging at 3.0 kW — power is cheap. Then it covers the house at 2.0 kW in about half an hour.") + #expect(PlanText.headline(nil, nowMs: now).text == "No plan yet.") + #expect(PlanText.headline(Plan(rev: 1, uptimeMs: 0, slots: [], stale: true, ceilingW: nil), nowMs: now).text.hasPrefix("Your box couldn't plan ahead")) + } + + @Test func pricesInTheChartsUnit() { + #expect(PriceUnits.text(144, "SEK") == "144.0") + #expect(PriceUnits.unit("EUR").perKwh == "cent/kWh") + #expect(PriceUnits.text(400, "CZK") == "4.00") + #expect(PriceUnits.unit("XYZ").perKwh == "XYZ/kWh") + let day: Double = 1_750_000_000_000 + let slots = [PriceSlot(startMs: day, durationMs: 3_600_000, spotMinor: 1, totalMinor: 1), PriceSlot(startMs: day + 7_200_000, durationMs: 3_600_000, spotMinor: 1, totalMinor: 1)] + #expect(PriceUnits.hasHole(slots, fromMs: day)) + #expect(PriceUnits.hasHole(Array(slots.suffix(1)), fromMs: day)) + #expect(!PriceUnits.hasHole(Array(slots.prefix(1)), fromMs: day)) + } + + @Test func savingsAreSignedMajorUnits() { + #expect(Savings.compact(1240) == "+12.4") + #expect(Savings.compact(-310) == "−3.10") + #expect(Savings.compact(12_345) == "+123") + let days = (1...9).map { Savings.Day(day: String(format: "2026-09-%02d", $0), savedOre: 100, resolution: $0 == 9 ? "no_prices" : "slot") } + let p = Savings.periods(days) + #expect(!p.today.available) + #expect(p.week.savedMinor == 600) + #expect(!p.week.complete) + } + + @Test func evSentencesSayOnlyWhatTheBoxSaid() throws { + let unplugged = Loadpoint(try JSON(parsing: Array(#"{"id":"a","plugged_in":false,"current_soc":0.5}"#.utf8))) + #expect(EVText.status(unplugged) == "Not plugged in") + #expect(unplugged.socPct == nil) + + let charging = Loadpoint(try JSON(parsing: Array(#"{"id":"a","plugged_in":true,"current_power_w":7200,"current_soc":0.41,"delivered_wh_session":3040}"#.utf8))) + #expect(EVText.status(charging) == "Charging at 7.2 kW") + #expect(charging.socPct == 41) + #expect(EVText.session(charging) == "3.0 kWh this session") + + let paused = Loadpoint(try JSON(parsing: Array(#"{"id":"a","plugged_in":true,"manual_active":true,"manual_charge_w":0,"manual":{"state":"paused"}}"#.utf8))) + #expect(EVText.isPaused(paused)) + #expect(EVText.status(paused).hasPrefix("Paused by you.")) + + let stale = Loadpoint(try JSON(parsing: Array(#"{"id":"a","plugged_in":true,"charger":{"known":true,"available":false}}"#.utf8))) + #expect(EVText.status(stale) == "Charger status is out of date. FTW cannot confirm whether the car is charging.") + + #expect(EVText.days(0) == "every day") + #expect(EVText.days(0b0011111) == "weekdays") + #expect(EVText.days(0b1100000) == "weekends") + #expect(EVText.days(0b0000101) == "Mon, Wed") + } + + @Test func chargeCurrentFallsBackLikeTheBoxPage() throws { + let bare = Loadpoint(try JSON(parsing: Array(#"{"id":"a","plugged_in":true}"#.utf8))) + #expect(EVText.current(bare) == EVText.Current(minA: 6, maxA: 16, wattsPerAmp: 690, phases: 3)) + let capped = Loadpoint(try JSON(parsing: Array(#"{"id":"a","plugged_in":true,"max_charge_w":11000,"min_charge_w":4140}"#.utf8))) + // 16 A is 11 040 W, above the charger's ceiling: the ceiling wins. + #expect(EVText.watts(capped, amps: 16) == 11_000) + #expect(EVText.readout(capped, amps: 16) == "16 A · 11.0 kW") + } + + @Test func scheduleClockConvertsThroughUTC() { + var cal = Calendar(identifier: .gregorian) + cal.timeZone = TimeZone(identifier: "Europe/Stockholm")! + let summer = Date(timeIntervalSince1970: 1_750_000_000) + let utc = EVText.minuteUTC(hour: 7, minute: 0, at: summer, calendar: cal) + #expect(utc == 5 * 60) + let back = EVText.localTime(minuteUTC: 300, at: summer, calendar: cal) + #expect(back.hour == 7 && back.minute == 0) + #expect(EVText.minuteUTC(hour: 25, minute: 99, calendar: cal) == nil) + } + + @Test func flowUsesMagnitudesAndDirections() { + let r = Flow.readings(fields: [F.gridW: -1500, F.pvW: -4000, F.batteryW: 1200, F.batterySoc: 612, F.loadW: 1300]) + let grid = r.nodes.first { $0.role == .grid }! + #expect(grid.kw == 1.5) + #expect(!grid.toHub) + #expect(grid.sub == "exporting") + let battery = r.nodes.first { $0.role == .battery }! + #expect(battery.sub == "charging") + #expect(battery.socPct == 61) + #expect(!r.nodes.contains { $0.role == .ev }) + let overlaid = Flow.withLoadpointEV([F.loadW: 9000, F.evW: 0], evW: 7400) + #expect(overlaid[F.evW] == 7400) + #expect(overlaid[F.loadW] == 1600) + } + + @Test func theBandMatchesTheWebAppsCases() { + func band(_ carrier: CarrierKind, transport: CarrierKind = .none, _ src: SourceState, age: Double?, _ phase: SessionPhase, wait: Double = 0, boot: Int? = nil, noCarrier: Bool = false) -> Freshness.Band { + Freshness.band(carrier: carrier, transport: transport, srcState: src, ageMs: age, phase: phase, waitMs: wait, bootPct: boot, noCarrier: noCarrier) + } + // A decision about the phone is not a connection fault. + let ended = band(.none, .stale, age: 7_200_000, .terminated) + #expect(ended.message == "Access ended") + // No carrier at all: no promise of a retry. + let lost = band(.none, .stale, age: 60_000, .failed, noCarrier: true) + #expect(lost.message == "Can't reach your box") + #expect(lost.tone == .lost) + #expect(lost.wait == nil) + // A carrier healing itself says so, with its elapsed time. + let healing = band(.none, .stale, age: 60_000, .failed, wait: 7_000) + #expect(healing.message == "Reconnecting to your box") + #expect(healing.wait == "7s") + #expect(healing.age == "1 min ago") + // Live only for live readings. + let quiet = band(.relay, .stale, age: 5_000, .streaming) + #expect(quiet.message == "Encrypted relay connected · readings") + #expect(quiet.tone == .stale) + #expect(!quiet.message.lowercased().contains("live")) + #expect(band(.relay, .live, age: 0, .streaming).message == "Live via encrypted relay") + #expect(band(.relay, .live, age: 0, .streaming).age == nil) + // A starting box shows its own progress. + let booting = band(.none, transport: .relay, .stale, age: 5_000, .booting, boot: 40) + #expect(booting.message == "Your box is starting") + #expect(booting.wait == "40%") + // After a restart the box cannot place the reading: a dash, never a guess. + #expect(band(.cache, transport: .relay, .stale, age: nil, .handshaking).age == "—") + #expect(band(.cache, transport: .relay, .stale, age: nil, .handshaking).message == "Securing encrypted relay") + } + + @Test func thePriceCardFindsTheCheapestTwoHoursAhead() { + let hour = 3_600_000.0 + let day = 1_750_000_000_000.0 + let totals: [Double] = [100, 120, 40, 30, 35, 200, -5, 90] + let slots = totals.enumerated().map { PriceSlot(startMs: day + Double($0.offset) * hour, durationMs: hour, spotMinor: $0.element, totalMinor: $0.element) } + let prices = Prices(zone: "SE3", currency: "SEK", slots: slots, stale: false) + let s = PriceStrip.summary(prices, nowMs: day + 1.5 * hour) + #expect(s.current?.totalMinor == 120) + // The slot already over is not ahead. + #expect(s.bars.count == 7) + #expect(s.bars.first?.current == true) + #expect(s.bars.first(where: { $0.minor == -5 })?.tone == .negative) + #expect(s.bars.first(where: { $0.minor == 200 })?.tone == .dear) + #expect(s.bars.first(where: { $0.minor == 30 })?.tone == .cheap) + // 30 then 35 beats 40 then 30: two whole hours in a row, the lowest mean. + #expect(s.cheapest == PriceStrip.Block(meanMinor: 32.5, startMs: day + 3 * hour, endMs: day + 5 * hour)) + #expect(PriceStrip.text(144.4, "SEK") == "144") + #expect(PriceStrip.text(14.44, "SEK") == "14.4") + #expect(PriceStrip.text(nil, "SEK") == "—") + } +} diff --git a/appleApp/FTWKit/Tests/FTWKitTests/FrameTests.swift b/appleApp/FTWKit/Tests/FTWKitTests/FrameTests.swift new file mode 100644 index 0000000..2e9f8cf --- /dev/null +++ b/appleApp/FTWKit/Tests/FTWKitTests/FrameTests.swift @@ -0,0 +1,120 @@ +import Foundation +import Testing +@testable import FTWKit + +@Suite struct FrameTests { + @Test func everySharedFrameDecodesAndReencodesByteForByte() throws { + let v = try InteropVectors.load() + #expect(!v.frames.isEmpty) + for want in v.frames { + let wire = Bytes(hex: want.bytes) + #expect(wire.count == want.bucket, "\(want.name)") + let frame = try Frame.decode(wire) + #expect(frame.lane == want.lane, "\(want.name)") + #expect(frame.flags == want.flags, "\(want.name)") + + // The whole payload survives a round trip, unknown keys included, + // because maps keep their order. + let payload = Array(wire[Frame.headerBytes..), .map(["seq": .int(1), "uptimeMs": .int(123_456_789)])] { + let frame = try Frame.encode(lane: Frame.laneControl, envelope: Envelope(t: "tick", b: body), bucket: 512) + #expect(frame.count == 512) + } + } + + @Test func refusesToGrowTheBucket() { + let big = Envelope(t: "x", b: .bytes(Bytes(repeating: 7, count: 600))) + #expect(throws: Frame.FrameError.self) { try Frame.encode(lane: 0, envelope: big, bucket: 512) } + } + + @Test func bulkPicksTheSmallestBucket() throws { + #expect(try Frame.encodeBulk(envelope: Envelope(t: "plan.get", id: 1)).count == 1024) + #expect(try Frame.encodeBulk(envelope: Envelope(t: "x", b: .bytes(Bytes(repeating: 0, count: 2000)))).count == 4096) + } + + @Test func junkIsRefusedWithACode() { + #expect(throws: Frame.FrameError.self) { try Frame.decode([1, 0]) } + #expect(throws: Frame.FrameError.self) { try Frame.decode([2, 0, 0, 0, 0, 0]) } + #expect(throws: Frame.FrameError.self) { try Frame.decode([1, 0, 0, 0, 0, 9, 0xa0]) } + } +} + +@Suite struct CBORTests { + // The web app's own bytes, as the Kotlin suite pinned them. + @Test func encodesTheAppsOwnHello() { + let hello = CBOR.map([ + ("t", .text("hello")), + ("b", .map([ + ("proto", .map([("min", .int(0)), ("max", .int(1))])), + ("app", .map([("build", .text("test")), ("ua", .text("pwa"))])), + ("locales", .array([.text("sv")])), + ])), + ]) + #expect(encodeCBOR(hello).hex == "a261746568656c6c6f6162a36570726f746fa2636d696e00636d61780163617070a2656275696c64647465737462756163707761676c6f63616c657381627376") + } + + @Test func encodesSub() { + let sub = CBOR.map([("t", .text("sub")), ("b", .map([("bucket", .int(512)), ("hz", .number(1))]))]) + #expect(encodeCBOR(sub).hex == "a26174637375626162a2666275636b657419020062687a01") + } + + @Test func floatsTakeTheShortestExactForm() { + // What cbor2, the web app's encoder, writes for the same numbers. + #expect(encodeCBOR(.map(["hz": .number(0.2)])).hex == "a162687afb3fc999999999999a") + #expect(encodeCBOR(.map(["hz": .number(1.5)])).hex == "a162687af93e00") + #expect(encodeCBOR(.map(["v": .number(0.5)])).hex == "a16176f93800") + #expect(encodeCBOR(.map(["v": .number(-3)])).hex == "a1617622") + #expect(encodeCBOR(.map(["v": .number(1e10)])).hex == "a161761b00000002540be400") + #expect(encodeCBOR(.map(["v": .number(4_294_967_296)])).hex == "a161761b0000000100000000") + } + + @Test func roundTripsEveryKind() throws { + let value = CBOR.map([ + ("u", .int(Int64(UInt32.max) + 5)), + ("n", .int(-1_000_000)), + ("b", .bytes([0, 1, 2, 255])), + ("s", .text("åäö")), + ("a", .array([.bool(true), .bool(false), .null])), + ("d", .double(0.1)), + ("f", .double(3.5)), + ]) + let decoded = try decodeCBOR(encodeCBOR(value)) + #expect(decoded == value) + #expect(decoded["n"]?.int64 == -1_000_000) + #expect(decoded["f"]?.double == 3.5) + } + + @Test func refusesDuplicateKeysAndTrailingBytes() { + #expect(throws: CBOR.DecodeError.self) { try decodeCBOR(Bytes(hex: "a2617401617402")) } + #expect(throws: CBOR.DecodeError.self) { try decodeCBOR(Bytes(hex: "0102")) } + } + + @Test func refusesLengthsPastTheInput() { + #expect(throws: CBOR.DecodeError.self) { try decodeCBOR(Bytes(hex: "5bffffffffffffffff")) } + #expect(throws: CBOR.DecodeError.self) { try decodeCBOR(Bytes(hex: "9bffffffffffffffff")) } + } + + @Test func readsIndefiniteLengths() throws { + #expect(try decodeCBOR(Bytes(hex: "9f0102ff")) == .array([.int(1), .int(2)])) + #expect(try decodeCBOR(Bytes(hex: "bf616101ff"))["a"] == .int(1)) + } +} diff --git a/appleApp/FTWKit/Tests/FTWKitTests/IdentityTests.swift b/appleApp/FTWKit/Tests/FTWKitTests/IdentityTests.swift new file mode 100644 index 0000000..000e8b1 --- /dev/null +++ b/appleApp/FTWKit/Tests/FTWKitTests/IdentityTests.swift @@ -0,0 +1,350 @@ +import Foundation +import Testing +@testable import FTWKit + +/// Vectors produced by running the web app's own identity code +/// (src/lib/identity and src/lib/carrier/rendezvous in srcfl/ftw-webapp). +struct IdentityVectors: Decodable { + struct Handle: Decodable { let epoch: Int64; let handle: String } + struct Rendezvous: Decodable { let secret: String; let handles: [Handle] } + struct PRFVector: Decodable { + let output: String + let credentialId: String + let lookupId: String + let writeKey: String + let signedMessage: String + let signature: String + } + struct Home: Decodable { let siteId: String; let label: String; let boxStaticKey: String; let rendezvousSecret: String } + struct Blob: Decodable { let escrowVersion: UInt32; let sealed: String; let deviceScalar: String; let homes: [Home] } + struct VaultCopy: Decodable { let publicKey: String; let iv: String; let ct: String; let scalar: String } + struct EnrollmentVector: Decodable { + let url: String + let boxStaticPublic: String + let pairingCode: String + let lanHint: String + let rendezvousSecret: String + let siteId: String + let fingerprint: String + let deviceIdOnBox: String + } + + let rendezvous: Rendezvous + let prf: PRFVector + let recoveryBlob: Blob + let vaultCopy: VaultCopy + let enrollment: EnrollmentVector + + static func load() throws -> IdentityVectors { + guard let url = Bundle.module.url(forResource: "identity-vectors", withExtension: "json", subdirectory: "Resources") else { + throw CocoaError(.fileNoSuchFile) + } + return try JSONDecoder().decode(IdentityVectors.self, from: Data(contentsOf: url)) + } +} + +@Suite struct CrossImplementationTests { + @Test func rendezvousHandlesMatchTheWebApp() throws { + let v = try IdentityVectors.load() + for h in v.rendezvous.handles { + #expect(try Rendezvous.handle(secret: Bytes(hex: v.rendezvous.secret), epoch: h.epoch) == h.handle) + } + #expect(Rendezvous.epoch(nowMs: 0) == 0) + #expect(Rendezvous.epoch(nowMs: Rendezvous.epochMs - 1) == 0) + #expect(Rendezvous.epoch(nowMs: Rendezvous.epochMs) == 1) + } + + @Test func passkeyDerivationsMatchTheWebApp() throws { + let v = try IdentityVectors.load() + let wrapping = PRF.wrappingKey(credentialID: v.prf.credentialId, prfOutput: Bytes(hex: v.prf.output)) + let keys = try #require(wrapping.escrow) + #expect(keys.lookupID == v.prf.lookupId) + #expect(keys.writeKey.hex == v.prf.writeKey) + // The web app's signature verifies under the key derived here. + #expect(Primitives.ed25519Verify(publicKey: keys.writeKey, signature: Bytes(hex: v.prf.signature), message: Bytes(hex: v.prf.signedMessage))) + // And the signature made here verifies under the web app's key. + let mine = try keys.sign(Bytes(hex: v.prf.signedMessage)) + #expect(Primitives.ed25519Verify(publicKey: Bytes(hex: v.prf.writeKey), signature: mine, message: Bytes(hex: v.prf.signedMessage))) + } + + @Test func aRecoveryCopyTheWebAppSealedOpensHere() throws { + let v = try IdentityVectors.load() + let keys = try #require(PRF.wrappingKey(credentialID: v.prf.credentialId, prfOutput: Bytes(hex: v.prf.output)).escrow) + let sealed = Bytes(hex: v.recoveryBlob.sealed) + #expect(sealed.count == RecoveryBlob.maxBytes) + let contents = try RecoveryBlob.open(key: keys.sealKey, sealed, escrowVersion: v.recoveryBlob.escrowVersion) + #expect(contents.deviceScalar.hex == v.recoveryBlob.deviceScalar) + #expect(contents.homes.map(\.siteId) == v.recoveryBlob.homes.map(\.siteId)) + #expect(contents.homes.map(\.label) == v.recoveryBlob.homes.map(\.label)) + #expect(contents.homes.map { $0.boxStaticKey.hex } == v.recoveryBlob.homes.map(\.boxStaticKey)) + #expect(contents.homes.map { $0.rendezvousSecret.hex } == v.recoveryBlob.homes.map(\.rendezvousSecret)) + + // The version is bound in: the same bytes under another number fail. + #expect(throws: RecoveryBlob.BlobError.self) { + _ = try RecoveryBlob.open(key: keys.sealKey, sealed, escrowVersion: v.recoveryBlob.escrowVersion + 1) + } + } + + @Test func aVaultCopyTheWebAppSealedOpensUnderTheSamePasskey() throws { + let v = try IdentityVectors.load() + let wrapping = PRF.wrappingKey(credentialID: v.prf.credentialId, prfOutput: Bytes(hex: v.prf.output)) + let plain = try Primitives.aesGCMOpen(key: wrapping.key, nonce: Bytes(hex: v.vaultCopy.iv), ciphertext: Bytes(hex: v.vaultCopy.ct)) + #expect(plain == Vault.pkcs8Prefix + Bytes(hex: v.vaultCopy.scalar)) + } + + @Test func pairingURLAndNamesMatchTheWebApp() throws { + let v = try IdentityVectors.load() + let e = try Enrollment.parse(scanned: v.enrollment.url) + #expect(e.boxStaticPublic.hex == v.enrollment.boxStaticPublic) + #expect(e.pairingCode.hex == v.enrollment.pairingCode) + #expect(e.lanHint == v.enrollment.lanHint) + #expect(e.rendezvousSecret.hex == v.enrollment.rendezvousSecret) + #expect(e.url() == v.enrollment.url) + #expect(SiteList.siteID(e.boxStaticPublic) == v.enrollment.siteId) + #expect(SiteList.fingerprint(e.boxStaticPublic) == v.enrollment.fingerprint) + let pub = Bytes(hex: v.vaultCopy.publicKey) + #expect(String(Base64url.encode(pub).prefix(8)) == v.enrollment.deviceIdOnBox) + } +} + +@Suite struct EnrollmentTests { + let good = Enrollment(boxStaticPublic: Bytes(repeating: 1, count: 32), pairingCode: Bytes(repeating: 2, count: 16), lanHint: "10.0.0.2:8080", rendezvousSecret: Bytes(repeating: 3, count: 32)) + + @Test func acceptsAURLAndABareFragment() throws { + let url = good.url() + #expect(try Enrollment.parse(scanned: url) == good) + #expect(try Enrollment.parse(scanned: " " + url + "\n") == good) + let fragment = String(url[url.firstIndex(of: "#")!...]) + #expect(try Enrollment.parse(scanned: fragment) == good) + } + + @Test func refusesAnotherHostOrPath() { + let url = good.url() + for bad in [url.replacingOccurrences(of: "app.ftw.energy", with: "app.ftw.energy.evil.example"), + url.replacingOccurrences(of: "https://", with: "http://"), + url.replacingOccurrences(of: "/p#", with: "/q#"), + "https://example.com/p" + url[url.firstIndex(of: "#")!...]] { + #expect(throws: EnrollmentError.self) { _ = try Enrollment.parse(scanned: bad) } + } + } + + @Test func versionErrorsNameTheSideThatIsBehind() { + let url = good.url() + do { + _ = try Enrollment.parse(scanned: url.replacingOccurrences(of: "#v2.", with: "#v1.")) + Issue.record("v1 parsed") + } catch let e as EnrollmentError { + #expect(e.code == "E_QR_VERSION") + #expect(e.help.contains("box needs a software update")) + } catch { Issue.record("\(error)") } + do { + _ = try Enrollment.parse(scanned: url.replacingOccurrences(of: "#v2.", with: "#v3.")) + Issue.record("v3 parsed") + } catch let e as EnrollmentError { + #expect(e.help.contains("newer version of the app")) + } catch { Issue.record("\(error)") } + } + + @Test func refusesNonCanonicalBase64AndWrongLengths() { + var short = good + short.rendezvousSecret = Bytes(repeating: 3, count: 16) + #expect(throws: EnrollmentError.self) { _ = try Enrollment.parse(scanned: short.url()) } + // A final character with bits set past the last byte. + #expect(throws: Base64url.DecodeError.self) { _ = try Base64url.decode("AB") } + #expect((try? Base64url.decode("AA")) == [0]) + } +} + +@Suite struct BoxCodeTests { + // Produced by the box's Go encoder, as the web app's suite pins them. + let vectors = [ + ("0000000000", "0000-0000"), + ("ffffffffff", "ZZZZ-ZZZZ"), + ("0000000001", "0000-0001"), + ("0123456789", "04HM-ASW9"), + ("deadbeef42", "VTPV-XVT2"), + ("8f1c00a57b", "HWE0-19BV"), + ("1084210842", "2222-2222"), + ] + + @Test func decodesWhatTheBoxDrew() throws { + for (hex, code) in vectors { + #expect(try BoxCode.decode(code).hex == hex, "\(code)") + } + } + + @Test func foldsWhatListenersMishear() throws { + #expect(try BoxCode.decode("IO1L-0000").hex == "0802100000") + #expect(try BoxCode.decode("l0i1-oooo").hex == "0802100000") + for typed in ["04hm-asw9", "04HMASW9", "04 HM AS W9", " 04hm asw9 ", "04HM\tASW9"] { + #expect(try BoxCode.decode(typed).hex == "0123456789") + } + #expect(BoxCode.fold("04hm-asw9") == "04HMASW9") + #expect(BoxCode.fold("04HMASW9ZZZZ") == "04HMASW9") + #expect(BoxCode.group("04HMASW9") == "04HM-ASW9") + } + + @Test func refusesWhatIsNotACode() { + #expect(throws: BoxCode.BoxCodeError.self) { _ = try BoxCode.decode("UUUU-UUUU") } + #expect(throws: BoxCode.BoxCodeError.self) { _ = try BoxCode.decode("04HM-ASW") } + #expect(throws: BoxCode.BoxCodeError.self) { _ = try BoxCode.decode("04HM!ASW9") } + } +} + +@MainActor +@Suite struct VaultTests { + @Test func enrollingMakesAPasskeyCopyAndALocalOne() async throws { + let store = MemoryStore() + let vault = Vault(store: store) + let passkeys = FakePasskeys() + let wrapping = try await vault.enrollWrappingKey(passkeys) + #expect(wrapping.source == .prf) + let pair = try vault.deviceKey(wrapping) + try vault.ensureLocalCopy(wrapping) + #expect(vault.credentialIDs.sorted() == ["Y3JlZC0x", "local"]) + #expect(vault.passkeyCredentialIDs == ["Y3JlZC0x"]) + + // Reading needs no prompt at all. + let silent = try #require(try vault.silentWrappingKey()) + #expect(try vault.deviceKey(silent).publicKey == pair.publicKey) + #expect(vault.devicePublic == pair.publicKey) + } + + @Test func aDeclineIsRethrownAndAFailureFallsBack() async throws { + let vault = Vault(store: MemoryStore()) + let passkeys = FakePasskeys() + passkeys.cancel = true + await #expect(throws: PasskeyCancelled.self) { _ = try await vault.enrollWrappingKey(passkeys) } + passkeys.cancel = false + passkeys.fail = true + #expect(try await vault.enrollWrappingKey(passkeys).source == .local) + } + + @Test func prfThatArrivesOnlyOnAssertionCostsOneMorePrompt() async throws { + let vault = Vault(store: MemoryStore()) + let passkeys = FakePasskeys() + passkeys.prfOnlyOnAssert = true + let wrapping = try await vault.enrollWrappingKey(passkeys) + #expect(wrapping.source == .prf) + #expect(passkeys.assertions == [["Y3JlZC0x"]]) + } + + @Test func restoreRefusesToOverwriteAnotherIdentity() async throws { + let vault = Vault(store: MemoryStore()) + let local = vault.localWrappingKey() + _ = try vault.deviceKey(local) + #expect(throws: Vault.VaultError.self) { try vault.restoreDeviceKey(local, scalar: randomBytes(32)) } + } + + @Test func theLastCopyCannotBeRemoved() throws { + let vault = Vault(store: MemoryStore()) + _ = try vault.deviceKey(vault.localWrappingKey()) + #expect(throws: Vault.VaultError.self) { try vault.removeCredential(Vault.localCredentialID) } + } + + @Test func stepUpNeverClaimsACeremonyThatDidNotHappen() async throws { + let vault = Vault(store: MemoryStore()) + let passkeys = FakePasskeys() + // Only a local copy: nothing to prompt with. + _ = try vault.deviceKey(vault.localWrappingKey()) + #expect(await vault.stepUp(passkeys) == .unavailable) + + let other = Vault(store: MemoryStore()) + let wrapping = try await other.enrollWrappingKey(passkeys) + _ = try other.deviceKey(wrapping) + #expect(await other.stepUp(passkeys) == .done) + passkeys.cancel = true + #expect(await other.stepUp(passkeys) == .declined) + passkeys.cancel = false + passkeys.fail = true + #expect(await other.stepUp(passkeys) == .unavailable) + } +} + +@MainActor +@Suite struct EscrowTests { + func setUp() -> (Vault, SiteList, FakeEscrowService, Escrow, FakePasskeys) { + let store = MemoryStore() + let vault = Vault(store: store) + let sites = SiteList(store: store) + let service = FakeEscrowService() + return (vault, sites, service, Escrow(vault: vault, sites: sites, transport: service.transport), FakePasskeys()) + } + + @Test func requestsArePaddedToOneLength() throws { + let body = Escrow.padded([("op", .string("get")), ("id", .string("abc"))]) + #expect(body.count == 1024) + let json = try JSONSerialization.jsonObject(with: Data(body)) as? [String: Any] + #expect(json?["op"] as? String == "get") + } + + @Test func pairingHoldsASealedCopyAndANewPhoneGetsTheHomeBack() async throws { + let (vault, sites, service, escrow, passkeys) = setUp() + let pairing = Pairing(vault: vault, sites: sites, escrow: escrow, passkeys: passkeys, now: { 1_000 }) + let enrollment = Enrollment(boxStaticPublic: Primitives.generateKeyPair().publicKey, pairingCode: randomBytes(16), lanHint: "", rendezvousSecret: randomBytes(32)) + let paired = try await pairing.pair(scanned: enrollment.url()) + #expect(await paired.sealed.value == .saved) + #expect(sites.get(paired.site.siteId)?.escrow == true) + #expect(service.rows.count == 1) + let devicePublic = try #require(vault.devicePublic) + + // A new phone: empty storage, the same synced passkey. + let store2 = MemoryStore() + let vault2 = Vault(store: store2) + let sites2 = SiteList(store: store2) + let escrow2 = Escrow(vault: vault2, sites: sites2, transport: service.transport) + let found = try await escrow2.recover(passkeys) + #expect(found.map(\.siteId) == [paired.site.siteId]) + #expect(found.first?.fingerprint == SiteList.fingerprint(enrollment.boxStaticPublic)) + try escrow2.adopt(try #require(found.first), nowMs: 2_000) + // The same identity the box already trusts, with no pairing code. + #expect(vault2.devicePublic == devicePublic) + #expect(sites2.get(paired.site.siteId)?.pairingCode == nil) + #expect(sites2.get(paired.site.siteId)?.rendezvousSecret?.byteArray == enrollment.rendezvousSecret) + #expect(try vault2.silentWrappingKey() != nil) + #expect(vault2.passkeyCredentialIDs == [passkeys.credentialID]) + } + + @Test func removingEmptiesTheCopyAndKeepsTheVersionGoing() async throws { + let (vault, sites, service, escrow, passkeys) = setUp() + let pairing = Pairing(vault: vault, sites: sites, escrow: escrow, passkeys: passkeys) + let enrollment = Enrollment(boxStaticPublic: Primitives.generateKeyPair().publicKey, pairingCode: randomBytes(16), lanHint: "", rendezvousSecret: randomBytes(32)) + let paired = try await pairing.pair(scanned: enrollment.url()) + _ = await paired.sealed.value + #expect(await escrow.remove(passkeys) == .removed) + let row = try #require(service.rows.values.first) + #expect(row.blob.isEmpty) + #expect(row.version == 2) + // Nothing held now reads as nothing held. + let store2 = MemoryStore() + #expect(try await Escrow(vault: Vault(store: store2), sites: SiteList(store: store2), transport: service.transport).recover(passkeys).isEmpty) + } + + @Test func noOptInCostsNoPromptAndNoRequest() async { + let (_, _, service, escrow, passkeys) = setUp() + #expect(await escrow.remove(passkeys) == .nothingToRemove) + #expect(passkeys.assertions.isEmpty) + #expect(service.requests == 0) + } + + @Test func offlineLeavesThePairingIntactAndUnmarked() async throws { + let (vault, sites, service, escrow, passkeys) = setUp() + service.offline = true + let pairing = Pairing(vault: vault, sites: sites, escrow: escrow, passkeys: passkeys) + let enrollment = Enrollment(boxStaticPublic: Primitives.generateKeyPair().publicKey, pairingCode: randomBytes(16), lanHint: "", rendezvousSecret: randomBytes(32)) + let paired = try await pairing.pair(scanned: enrollment.url()) + #expect(await paired.sealed.value == .unreachable) + #expect(sites.get(paired.site.siteId)?.escrow == false) + #expect(sites.get(paired.site.siteId) != nil) + } + + @Test func aBoxCodeArmsAKnownHomeOnly() async throws { + let (vault, sites, _, escrow, passkeys) = setUp() + let pairing = Pairing(vault: vault, sites: sites, escrow: escrow, passkeys: passkeys) + #expect(throws: BoxCode.BoxCodeError.self) { try pairing.redeemBoxCode(siteId: "nope", typed: "04HM-ASW9") } + let enrollment = Enrollment(boxStaticPublic: Primitives.generateKeyPair().publicKey, pairingCode: randomBytes(16), lanHint: "", rendezvousSecret: randomBytes(32)) + let paired = try await pairing.pair(scanned: enrollment.url(), holdCopy: false) + try pairing.redeemBoxCode(siteId: paired.site.siteId, typed: "04hm asw9") + #expect(sites.get(paired.site.siteId)?.pairingCode?.byteArray.hex == "0123456789") + } +} diff --git a/appleApp/FTWKit/Tests/FTWKitTests/LiveBoxTests.swift b/appleApp/FTWKit/Tests/FTWKitTests/LiveBoxTests.swift new file mode 100644 index 0000000..2e4a256 --- /dev/null +++ b/appleApp/FTWKit/Tests/FTWKitTests/LiveBoxTests.swift @@ -0,0 +1,99 @@ +import Foundation +import Testing +@testable import FTWKit + +/// Against a real FTW box, through the production relay. +/// +/// Off unless FTW_LIVE_URL holds a pairing URL the box just minted: +/// +/// curl -s -X POST localhost:8080/api/app-link/pairing -d '{"role":"owner"}' +/// FTW_LIVE_URL='https://app.ftw.energy/p#v2...' swift test --filter LiveBox +/// +/// On Linux, whose Foundation has no WebSocket client, run a bridge that +/// carries frames to the relay unchanged and set FTW_LIVE_RELAY to it. +/// +/// The passkey ceremony cannot run here, so the device key is wrapped under +/// the local key alone. Everything after that is the shipped path: the real +/// socket, the rotating handle, Noise IK with the pairing code in message 1, +/// the session, and the box's own API over it. +@MainActor +@Suite(.enabled(if: ProcessInfo.processInfo.environment["FTW_LIVE_URL"] != nil), .serialized) +struct LiveBoxTests { + @Test func pairsStreamsAndCallsTheBoxOverTheRelay() async throws { + let url = try #require(ProcessInfo.processInfo.environment["FTW_LIVE_URL"]) + let enrollment = try Enrollment.parse(scanned: url) + let vault = Vault(store: MemoryStore()) + let device = try vault.deviceKey(vault.localWrappingKey()) + + let scheduler = LiveScheduler() + // FTW_LIVE_RELAY points at a local bridge where Foundation has no + // WebSocket client of its own (Linux); elsewhere the real socket. + let relay: RelayCarrier + if let bridge = ProcessInfo.processInfo.environment["FTW_LIVE_RELAY"], let bridgeURL = URL(string: bridge) { + #if os(Linux) + relay = RelayCarrier(url: bridgeURL, secret: enrollment.rendezvousSecret, scheduler: scheduler, makeSocket: PlainWebSocket.factory()) + #else + relay = RelayCarrier(url: bridgeURL, secret: enrollment.rendezvousSecret, scheduler: scheduler) + #endif + } else { + relay = RelayCarrier(secret: enrollment.rendezvousSecret, scheduler: scheduler) + } + let noise = NoiseCarrier( + inner: relay, + staticKey: device, + remoteStatic: enrollment.boxStaticPublic, + prologue: NoiseCarrier.prologue(boxStaticKey: enrollment.boxStaticPublic), + handshakePayload: enrollment.pairingCode, + scheduler: scheduler + ) + let session = Session(build: "swift-live-test", ua: "test", scheduler: scheduler) + session.connect(noise) + defer { session.close() } + + let started = Date() + while session.state.phase != .streaming, Date().timeIntervalSince(started) < 40 { + try await Task.sleep(nanoseconds: 200_000_000) + } + print("live: phase \(session.state.phase) after \(String(format: "%.1f", Date().timeIntervalSince(started))) s, role \(session.state.role), caps \(session.state.caps.sorted())") + #expect(session.state.phase == .streaming) + #expect(session.state.heardFromBox) + #expect(session.state.fields[Contract.FID.gridW] != nil) + print("live: fields \(session.state.fields.sorted { $0.key < $1.key })") + + // A second of telemetry, then the box's own API over the session. + let uptime = session.state.uptimeMs + try await Task.sleep(nanoseconds: 2_500_000_000) + #expect(session.state.uptimeMs > uptime) + + let status = try await session.api(APIRequest(method: .get, path: "/api/status")) + #expect(status.status == 200) + let json = try JSON(parsing: status.body) + print("live: /api/status grid_w \(json["grid_w"]?.number ?? .nan), \(status.body.count) bytes") + #expect(json["grid_w"]?.number != nil) + + let plan = try await session.plan() + print("live: plan rev \(plan.rev), \(plan.slots.count) slots, stale \(plan.stale)") + + var tiles = 0 + let end = try await session.history(HistQuery(series: ["grid_w", "pv_w", "battery_w", "load_w"], res: .fiveMinutes, fromMs: Date().timeIntervalSince1970 * 1000 - 86_400_000, toMs: Date().timeIntervalSince1970 * 1000, have: [], maxPoints: 1500)) { _ in tiles += 1 } + print("live: history \(tiles) tiles, served \(end.resActual.rawValue)") + + // Reaching an actuating route through the passthrough is refused and + // points at the command instead. + do { + _ = try await session.api(APIRequest(method: .post, path: "/api/mode", body: Array(#"{"mode":"self_consumption"}"#.utf8))) + Issue.record("an actuating route answered through the passthrough") + } catch let refusal as BoxRefusal { + print("live: POST /api/mode refused with \(refusal.detail.code)") + #expect(refusal.detail.code == "E_USE_CMD") + } + + // The mode, set through the command door the box keeps for it. + if let current = session.state.fields[Contract.FID.mode].flatMap({ Int($0) }), current < session.state.modes.count { + let key = session.state.modes[current].key + let result = try await session.command(Contract.opSetMode, args: [("mode", .text(key))]) + print("live: site.mode.set \(key) -> \(result.state.rawValue)") + #expect(result.state == .applied || result.state == .unconfirmed) + } + } +} diff --git a/appleApp/FTWKit/Tests/FTWKitTests/Loopback.swift b/appleApp/FTWKit/Tests/FTWKitTests/Loopback.swift new file mode 100644 index 0000000..53bba50 --- /dev/null +++ b/appleApp/FTWKit/Tests/FTWKitTests/Loopback.swift @@ -0,0 +1,179 @@ +import Foundation +@testable import FTWKit + +/// The box's end of Noise, around a `SimulatedBox`: what the Go box does in +/// front of its protocol handler. +@MainActor +final class NoiseBoxEndpoint { + let box: SimulatedBox + let staticKey = Primitives.generateKeyPair() + private var handshake: HandshakeState? + private var transport: NoiseTransport? + var toApp: (@MainActor (Bytes) -> Void)? + private(set) var handshakePayloads: [Bytes] = [] + /// Answer handshakes with silence, as a box does for a phone it refuses. + var refuse = false + + init(box: SimulatedBox) { + self.box = box + box.send = { [weak self] frame in + guard let self, let transport = self.transport, let wire = try? transport.encrypt(frame) else { return } + self.toApp?(wire) + } + } + + var prologue: Bytes { NoiseCarrier.prologue(boxStaticKey: staticKey.publicKey) } + + func receive(_ bytes: Bytes) { + if let transport, let frame = try? transport.decrypt(bytes) { + box.receive(frame) + return + } + // Anything else of the right length is a new handshake. + guard bytes.count >= Noise.message1Overhead, !refuse else { return } + let hs = HandshakeState.responder(staticKey: staticKey, prologue: prologue) + guard let payload = try? hs.readMessage(bytes), let reply = try? hs.writeMessage() else { return } + handshakePayloads.append(payload) + transport = NoiseTransport(try! hs.split()) + toApp?(reply) + } + + func dropSession() { + transport = nil + } +} + +/// A relay room with one box in it, reached through fake sockets. +@MainActor +final class FakeRelay { + let scheduler: ManualScheduler + let endpoint: NoiseBoxEndpoint + var latencyMs: Double = 10 + var boxOnline = true + private(set) var dialled: [URL] = [] + private(set) var sockets: [FakeSocket] = [] + + init(scheduler: ManualScheduler, endpoint: NoiseBoxEndpoint) { + self.scheduler = scheduler + self.endpoint = endpoint + endpoint.toApp = { [weak self] bytes in + guard let self, let socket = self.sockets.last(where: { !$0.closed }) else { return } + _ = self.scheduler.after(self.latencyMs) { socket.deliver(bytes) } + } + } + + var factory: WebSocketFactory { + { [unowned self] url, events in + self.dialled.append(url) + let socket = FakeSocket(relay: self, events: events) + self.sockets.append(socket) + _ = self.scheduler.after(self.latencyMs) { + guard !socket.closed else { return } + events.onOpen() + if self.boxOnline { events.onText("ready") } + } + return socket + } + } + + var current: FakeSocket? { sockets.last(where: { !$0.closed }) } + + /// The relay closes the socket with a code, as it does on a rotation. + func closeCurrent(code: Int, reason: String) { + guard let socket = current else { return } + socket.closed = true + socket.events.onClose(code, reason) + } + + func boxLeaves() { + boxOnline = false + current?.events.onText("gone") + } + + func boxReturns() { + boxOnline = true + endpoint.dropSession() + current?.events.onText("ready") + } +} + +@MainActor +final class FakeSocket: WebSocketConnection { + unowned let relay: FakeRelay + let events: WebSocketEvents + var closed = false + private(set) var sent: [Bytes] = [] + + init(relay: FakeRelay, events: WebSocketEvents) { + self.relay = relay + self.events = events + } + + func send(_ data: Bytes) { + guard !closed else { return } + sent.append(data) + _ = relay.scheduler.after(relay.latencyMs) { [weak self] in + guard let self, !self.closed else { return } + self.relay.endpoint.receive(data) + } + } + + func deliver(_ bytes: Bytes) { + guard !closed else { return } + events.onBinary(bytes) + } + + func close() { + closed = true + } +} + +/// The whole stack, as the app builds it, against the simulated box. +@MainActor +struct Rig { + let scheduler: ManualScheduler + let box: SimulatedBox + let endpoint: NoiseBoxEndpoint + let relay: FakeRelay + let relayCarrier: RelayCarrier + let noise: NoiseCarrier + let session: Session + let pairingCode: Bytes + + init(requireStepUp: Bool = false, connect: Bool = true) { + let scheduler = ManualScheduler() + self.scheduler = scheduler + box = SimulatedBox(scheduler: scheduler, requireStepUp: requireStepUp) + endpoint = NoiseBoxEndpoint(box: box) + relay = FakeRelay(scheduler: scheduler, endpoint: endpoint) + pairingCode = randomBytes(16) + relayCarrier = RelayCarrier(url: URL(string: "wss://relay.test")!, secret: Bytes(repeating: 9, count: 32), scheduler: scheduler, makeSocket: relay.factory) + noise = NoiseCarrier( + inner: relayCarrier, + staticKey: Primitives.generateKeyPair(), + remoteStatic: endpoint.staticKey.publicKey, + prologue: endpoint.prologue, + handshakePayload: pairingCode, + scheduler: scheduler + ) + session = Session(build: "test", ua: "test", scheduler: scheduler) + if connect { session.connect(noise) } + } + + /// Let spawned tasks reach their suspension points. + func settle() async { + for _ in 0..<20 { await Task.yield() } + } + + /// Advance in small steps, letting tasks run between them, so a request + /// sent by a task is on the wire before its answer is due. + func run(_ ms: Double, step: Double = 10) async { + var left = ms + while left > 0 { + await settle() + scheduler.advance(min(step, left)) + left -= step + } + await settle() + } +} diff --git a/appleApp/FTWKit/Tests/FTWKitTests/NoiseTests.swift b/appleApp/FTWKit/Tests/FTWKitTests/NoiseTests.swift new file mode 100644 index 0000000..7cf2249 --- /dev/null +++ b/appleApp/FTWKit/Tests/FTWKitTests/NoiseTests.swift @@ -0,0 +1,182 @@ +import Foundation +import Testing +@testable import FTWKit + +@Suite struct NoiseTests { + /// Cacophony vector for Noise_IK_25519_ChaChaPoly_SHA256, the same one + /// the web app and the Kotlin client test against. + let vector = [ + "init_prologue": "4a6f686e2047616c74", + "init_static": "e61ef9919cde45dd5f82166404bd08e38bceb5dfdfded0a34c8df7ed542214d1", + "init_ephemeral": "893e28b9dc6ca8d611ab664754b8ceb7bac5117349a4439a6b0569da977c464a", + "init_remote_static": "31e0303fd6418d2f8c0e78b91f22e8caed0fbe48656dcf4767e4834f701b8f62", + "resp_prologue": "4a6f686e2047616c74", + "resp_static": "4a3acbfdb163dec651dfa3194dece676d437029c62a408b4c5ea9114246e4893", + "resp_ephemeral": "bbdb4cdbd309f1a1f2e1456967fe288cadd6f712d65dc7b7793d5e63da6b375b", + "handshake_hash": "0b0f68fb0c27e03ce9b97565995ed4838cc0581b762ef72b062f6a546419fad7", + ] + + let messages: [(String, String)] = [ + ("4c756477696720766f6e204d69736573", + "ca35def5ae56cec33dc2036731ab14896bc4c75dbb07a61f879f8e3afa4c7944718da798efbcd91528520204f904b9bd6c7413dccdc214d951e15253e39987f18146e8cd0873654207148333479d4d16c289f0294b29960a72f48e0b7bba2e89083169825e59642148d492020664ccf7"), + ("4d757272617920526f746862617264", + "95ebc60d2b1fa672c1f46a8aa265ef51bfe38e7ccb39ec5be34069f1448088435361e70b2ed446e6c9ec387d1d6b3b840f194e373979d241b203c4acafccf5"), + ("462e20412e20486179656b", "050e9f3c8fac16b68dbce8f8c4bfbf6617c897f9ada4aa29aa19c8"), + ("4361726c204d656e676572", "344233a6cabb7141d80f3da2fedc311d9646bbb0f505afe403a667"), + ("4a65616e2d426170746973746520536179", "62cdeeb172ad7ade7aa7d9e069da5790f12331bfa00177787a1d0810c67dc3b2b4"), + ("457567656e2042f6686d20766f6e2042617765726b", + "029bead1b40992327044d409d9a1f3ad8f36c3c452775d557e18bbeb2e8dfcead32d514024"), + ] + + func key(_ name: String) throws -> Primitives.KeyPair { + try Primitives.keyPair(fromSecret: Bytes(hex: vector[name]!)) + } + + @Test func namesTheProtocol() { + #expect(Noise.protocolName == "Noise_IK_25519_ChaChaPoly_SHA256") + #expect(Array(Noise.protocolName.utf8).count == 32) + } + + @Test func cacophonyHandshakeAndTransport() throws { + let initiator = try HandshakeState.initiator( + staticKey: key("init_static"), + remoteStatic: Bytes(hex: vector["init_remote_static"]!), + prologue: Bytes(hex: vector["init_prologue"]!), + ephemeral: key("init_ephemeral") + ) + let responder = HandshakeState.responder( + staticKey: try key("resp_static"), + prologue: Bytes(hex: vector["resp_prologue"]!), + ephemeral: try key("resp_ephemeral") + ) + + let m1 = try initiator.writeMessage(Bytes(hex: messages[0].0)) + #expect(m1.hex == messages[0].1) + #expect(try responder.readMessage(m1).hex == messages[0].0) + + let m2 = try responder.writeMessage(Bytes(hex: messages[1].0)) + #expect(m2.hex == messages[1].1) + #expect(try initiator.readMessage(m2).hex == messages[1].0) + + let a = try initiator.split() + let b = try responder.split() + #expect(a.handshakeHash.hex == vector["handshake_hash"]) + #expect(b.handshakeHash.hex == vector["handshake_hash"]) + #expect(a.remoteStatic.hex == vector["init_remote_static"]) + #expect(b.remoteStatic == (try key("init_static")).publicKey) + + for (i, msg) in messages.dropFirst(2).enumerated() { + let fromInitiator = i % 2 == 0 + let sender = fromInitiator ? a.send : b.send + let receiver = fromInitiator ? b.recv : a.recv + let ct = try sender.encrypt(ad: [], Bytes(hex: msg.0)) + #expect(ct.hex == msg.1) + #expect(try receiver.decrypt(ad: [], ct).hex == msg.0) + } + } + + @Test func splitTwiceIsRefused() throws { + let box = Primitives.generateKeyPair() + let app = Primitives.generateKeyPair() + let i = try HandshakeState.initiator(staticKey: app, remoteStatic: box.publicKey) + let r = HandshakeState.responder(staticKey: box) + _ = try r.readMessage(try i.writeMessage()) + _ = try i.readMessage(try r.writeMessage()) + _ = try i.split() + #expect(throws: Noise.NoiseError.self) { try i.split() } + } + + @Test func aStrayMessageTwoLeavesTheHandshakeWaiting() throws { + // Two phones in one relay room read each other's 48-byte replies. A + // reply that does not authenticate must not poison the state. + let box = Primitives.generateKeyPair() + let app = Primitives.generateKeyPair() + let i = try HandshakeState.initiator(staticKey: app, remoteStatic: box.publicKey) + let r = HandshakeState.responder(staticKey: box) + _ = try r.readMessage(try i.writeMessage()) + let genuine = try r.writeMessage() + #expect(throws: Noise.NoiseError.self) { _ = try i.readMessage(randomBytes(48)) } + _ = try i.readMessage(genuine) + #expect(i.isComplete) + } + + @Test func aPinnedKeyThatIsNotTheBoxFailsAtTheFirstDecryption() throws { + let box = Primitives.generateKeyPair() + let impostor = Primitives.generateKeyPair() + let app = Primitives.generateKeyPair() + let i = try HandshakeState.initiator(staticKey: app, remoteStatic: box.publicKey) + let r = HandshakeState.responder(staticKey: impostor) + let m1 = try i.writeMessage() + #expect(throws: Noise.NoiseError.self) { _ = try r.readMessage(m1) } + } + + @Test func sharedInteropVectors() throws { + let v = try InteropVectors.load() + #expect(v.protocolName == Noise.protocolName) + let hs = v.handshake + let appStatic = try Primitives.keyPair(fromSecret: Bytes(hex: hs.initiatorStatic)) + let boxStatic = try Primitives.keyPair(fromSecret: Bytes(hex: hs.responderStatic)) + #expect(appStatic.publicKey.hex == hs.initiatorStaticPublic) + #expect(boxStatic.publicKey.hex == hs.responderStaticPublic) + + let app = try HandshakeState.initiator( + staticKey: appStatic, + remoteStatic: boxStatic.publicKey, + prologue: Bytes(hex: hs.prologue), + ephemeral: try Primitives.keyPair(fromSecret: Bytes(hex: hs.initiatorEphemeral)) + ) + let box = HandshakeState.responder( + staticKey: boxStatic, + prologue: Bytes(hex: hs.prologue), + ephemeral: try Primitives.keyPair(fromSecret: Bytes(hex: hs.responderEphemeral)) + ) + let m1 = try app.writeMessage(Bytes(hex: hs.message1Payload)) + #expect(m1.hex == hs.message1) + #expect(try box.readMessage(m1).hex == hs.message1Payload) + let m2 = try box.writeMessage(Bytes(hex: hs.message2Payload)) + #expect(m2.hex == hs.message2) + #expect(try app.readMessage(m2).hex == hs.message2Payload) + + let appSide = NoiseTransport(try app.split()) + let boxSide = NoiseTransport(try box.split()) + #expect(appSide.handshakeHash.hex == hs.handshakeHash) + + let frames = Dictionary(uniqueKeysWithValues: v.frames.map { ($0.name, Bytes(hex: $0.bytes)) }) + for step in v.transport { + let frame = try #require(frames[step.frame]) + if step.from == "app" { + #expect(appSide.nextSeq == step.seq) + let wire = try appSide.encrypt(frame) + #expect(wire.hex == step.wire, "app frame \(step.frame) at \(step.seq)") + #expect(try boxSide.decrypt(wire) == frame) + } else { + let wire = Bytes(hex: step.wire) + #expect(try appSide.decrypt(wire) == frame, "box frame \(step.frame) at \(step.seq)") + } + } + } + + @Test func replayWindowRefusesARepeatAndAcceptsLateFrames() throws { + let box = Primitives.generateKeyPair() + let appKey = Primitives.generateKeyPair() + let i = try HandshakeState.initiator(staticKey: appKey, remoteStatic: box.publicKey) + let r = HandshakeState.responder(staticKey: box) + _ = try r.readMessage(try i.writeMessage()) + _ = try i.readMessage(try r.writeMessage()) + let app = NoiseTransport(try i.split()) + let boxSide = NoiseTransport(try r.split()) + + let w0 = try boxSide.encrypt([0]) + let w1 = try boxSide.encrypt([1]) + let w2 = try boxSide.encrypt([2]) + #expect(try app.decrypt(w2) == [2]) + #expect(try app.decrypt(w0) == [0]) + #expect(throws: Noise.NoiseError.self) { _ = try app.decrypt(w0) } + #expect(try app.decrypt(w1) == [1]) + + // A forged frame does not move the window. + var forged = try boxSide.encrypt([3]) + forged[forged.count - 1] ^= 1 + #expect(throws: Noise.NoiseError.self) { _ = try app.decrypt(forged) } + } +} diff --git a/appleApp/FTWKit/Tests/FTWKitTests/PlainWebSocket.swift b/appleApp/FTWKit/Tests/FTWKitTests/PlainWebSocket.swift new file mode 100644 index 0000000..7553a87 --- /dev/null +++ b/appleApp/FTWKit/Tests/FTWKitTests/PlainWebSocket.swift @@ -0,0 +1,181 @@ +#if os(Linux) +import Foundation +import Glibc +@testable import FTWKit + +/// A minimal RFC 6455 client over a plain TCP socket, for the live test on +/// Linux only: Foundation there is built on a libcurl without WebSocket +/// support, where Apple platforms have URLSessionWebSocketTask. It talks to +/// a local bridge that carries the frames to the relay unchanged. +@MainActor +final class PlainWebSocket: WebSocketConnection { + private enum Event: Sendable { + case open + case text(String) + case binary([UInt8]) + case close(Int, String) + } + + private let fd: Int32 + private var closed = false + + static func factory() -> WebSocketFactory { + { url, events in PlainWebSocket(url: url, events: events) } + } + + init(url: URL, events: WebSocketEvents) { + fd = socket(AF_INET, Int32(SOCK_STREAM.rawValue), 0) + let host = url.host ?? "127.0.0.1" + let port = UInt16(url.port ?? 80) + let path = url.path.isEmpty ? "/" : url.path + let (stream, continuation) = AsyncStream.makeStream() + let fd = self.fd + + // One consumer on the main actor, so events arrive in order. + Task { @MainActor [weak self] in + for await event in stream { + guard let self, !self.closed || { if case .close = event { return true }; return false }() else { continue } + switch event { + case .open: events.onOpen() + case .text(let t): events.onText(t) + case .binary(let b): events.onBinary(b) + case .close(let code, let reason): + self.closed = true + events.onClose(code, reason) + } + } + } + + Thread.detachNewThread { + var addr = sockaddr_in() + addr.sin_family = sa_family_t(AF_INET) + addr.sin_port = port.bigEndian + addr.sin_addr.s_addr = inet_addr(host == "localhost" ? "127.0.0.1" : host) + let ok = withUnsafePointer(to: &addr) { + $0.withMemoryRebound(to: sockaddr.self, capacity: 1) { Glibc.connect(fd, $0, socklen_t(MemoryLayout.size)) } + } + guard ok == 0 else { + continuation.yield(.close(1006, "")) + continuation.finish() + return + } + let key = Data((0..<16).map { _ in UInt8.random(in: 0...255) }).base64EncodedString() + let request = "GET \(path) HTTP/1.1\r\nHost: \(host):\(port)\r\nUpgrade: websocket\r\nConnection: Upgrade\r\nSec-WebSocket-Key: \(key)\r\nSec-WebSocket-Version: 13\r\n\r\n" + PlainWebSocket.write(fd, Array(request.utf8)) + + var buffer = [UInt8]() + func fill(_ n: Int) -> Bool { + var chunk = [UInt8](repeating: 0, count: 65_536) + while buffer.count < n { + let got = recv(fd, &chunk, chunk.count, 0) + if got <= 0 { return false } + buffer += chunk[0..= 2 ? Int(payload[0]) << 8 | Int(payload[1]) : 1005 + let reason = payload.count > 2 ? String(decoding: payload[2...], as: UTF8.self) : "" + continuation.yield(.close(code, reason)) + continuation.finish() + Glibc.close(fd) + return + case 0x9: + PlainWebSocket.write(fd, PlainWebSocket.frame(opcode: 0xA, payload)) + case 0x0, 0x1, 0x2: + if opcode != 0 { messageOpcode = opcode; message = [] } + message += payload + if fin { + continuation.yield(messageOpcode == 1 ? .text(String(decoding: message, as: UTF8.self)) : .binary(message)) + message = [] + } + default: + break + } + } + continuation.yield(.close(1006, "")) + continuation.finish() + } + } + + nonisolated static func frame(opcode: UInt8, _ payload: [UInt8]) -> [UInt8] { + var out: [UInt8] = [0x80 | opcode] + if payload.count < 126 { + out.append(0x80 | UInt8(payload.count)) + } else if payload.count < 65_536 { + out.append(0x80 | 126) + out.append(UInt8(payload.count >> 8)) + out.append(UInt8(payload.count & 0xff)) + } else { + out.append(0x80 | 127) + for shift in stride(from: 56, through: 0, by: -8) { out.append(UInt8(truncatingIfNeeded: payload.count >> shift)) } + } + let mask = (0..<4).map { _ in UInt8.random(in: 0...255) } + out += mask + for (i, b) in payload.enumerated() { out.append(b ^ mask[i % 4]) } + return out + } + + nonisolated static func write(_ fd: Int32, _ bytes: [UInt8]) { + var sent = 0 + while sent < bytes.count { + let n = bytes[sent...].withUnsafeBytes { Glibc.send(fd, $0.baseAddress, $0.count, Int32(MSG_NOSIGNAL)) } + if n <= 0 { return } + sent += n + } + } + + func send(_ data: Bytes) { + guard !closed else { return } + PlainWebSocket.write(fd, Self.frame(opcode: 0x2, data)) + } + + func close() { + guard !closed else { return } + closed = true + PlainWebSocket.write(fd, Self.frame(opcode: 0x8, [0x03, 0xe8])) + shutdown(fd, Int32(SHUT_RDWR)) + } +} +#endif diff --git a/appleApp/FTWKit/Tests/FTWKitTests/Resources/identity-vectors.json b/appleApp/FTWKit/Tests/FTWKitTests/Resources/identity-vectors.json new file mode 100644 index 0000000..a8d7b02 --- /dev/null +++ b/appleApp/FTWKit/Tests/FTWKitTests/Resources/identity-vectors.json @@ -0,0 +1,64 @@ +{ + "note": "Generated from srcfl/ftw-webapp by the FTWKit identity vector script. Do not hand-edit.", + "rendezvous": { + "secret": "00112233445566778899aabbccddeeff00112233445566778899aabbccddeeff", + "handles": [ + { + "epoch": 0, + "handle": "beed4f23fe34a00e59330db079679919" + }, + { + "epoch": 42, + "handle": "446ac61f697e3fc056fee30aa17af119" + }, + { + "epoch": 493827, + "handle": "678f644aee950c0bffd06f85819862db" + } + ] + }, + "prf": { + "output": "a0a1a2a3a4a5a6a7a8a9aaabacadaeafb0b1b2b3b4b5b6b7b8b9babbbcbdbebf", + "credentialId": "Y3JlZC0x", + "lookupId": "yZ3HcAQb54jgUNmy5i-ncFpsg9g9fSTIGxy-k_TiszU", + "writeKey": "df14a14ef739adeac0cea8e5224a50476e27571b91b0e47584eefc494ce5bfc0", + "signedMessage": "6674772d657363726f773a76313a74657374", + "signature": "6ee8f87feb18e696bf053c9051ba8ba7336ffdb51cc67f2751e63542e9c2ae6f0c272be1e74611d6758ffa9a72611476009e45a54b4c47dfbff40c808520810d" + }, + "recoveryBlob": { + "escrowVersion": 7, + "sealed": "022af5ddaf04748e7c57aefafd2cb96bf088426225297f2444547814a5c07dee323e74d9ddea720057c52f0ea1f1533287ff32577a05d8a4be010c6423ba946b1ef37be68f55f7e894bc1c1b76c1bdfd15bdaf91600d83b4afcf1236af305ba9861c9dab95ac3c31ef1a0f3eac34781b331e1dfed76274ad8e7dcb7c4e2aacb8ff139c893b8c3b8cfd10a0ee607256765c660610b3bb80d2c4f5c3aa7412b1dad13bd6f315f727a40089d1f047444c934c4d4c08b457e6609a26b3047529336eb0c1bfbad01f3b968d8b4c3da975eef85ab56485c64af335f98c1a9ae5f91f8432cf71a479cd39adb30757708f5d0b223f10e437606558835745c636f8f7cc1e05c4aa828be564744aef33de858d5c98a39a21744e041ca9f913e91594718cbc2293fcd1d47303f6b2b80e391d45ceeb7e7cd8553838033286079cef8036f5d10edd75b49d8bba89c36e083e78ac56a37727aa572a26d53bf0a864443f9b92a3cd1b0696421c54aaacfb2c447e03ad0233e745c9063a2bd56f3f622367b30cf0123e30786ac6dda668310e0e5d3d8f5832d6a9c5db8772bd143bb3b75de5fbe3efd2a1334f2d02e77e50e16d7749bb8480ab1de7da42f7790e801e2562a0660410f47133c87ff17ebdcf452ffb914766733e8a1daea322387d6d1af0d17cd59b35d0e352738f761feb8ef8baf78ef0f193c417f00990fbe45039fe52561e87be", + "deviceScalar": "5f5f5f5f5f5f5f5f6060606060606060717171717171717182828282828282f2", + "homes": [ + { + "siteId": "deb7e3fa44c17f16", + "label": "Home", + "boxStaticKey": "97f74fb8f22ab62e60f9d725835a928ae111775f1cb013375603120c159cb807", + "rendezvousSecret": "1111111111111111111111111111111111111111111111111111111111111111" + }, + { + "siteId": "cabin-0001", + "label": "Stugan 🌲", + "boxStaticKey": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "rendezvousSecret": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb" + } + ] + }, + "vaultCopy": { + "publicKey": "97f74fb8f22ab62e60f9d725835a928ae111775f1cb013375603120c159cb807", + "devicePublicFromVault": "97f74fb8f22ab62e60f9d725835a928ae111775f1cb013375603120c159cb807", + "iv": "f5d0d835d4ad37910c35399d", + "ct": "24a5d928204136e3808b3e2c3ac862ea55e71b9a91ff373fb2224bfcad6e06d245aafa30e18cd8d8aacad79d9f43976083c4c85381c6f176a8fc6adb51ac6fe5", + "scalar": "5f5f5f5f5f5f5f5f6060606060606060717171717171717182828282828282f2" + }, + "enrollment": { + "url": "https://app.ftw.energy/p#v2.l_dPuPIqti5g-dclg1qSiuERd18csBM3VgMSDBWcuAc.ASNFZ4mrze8BI0VniavN7w.MTkyLjE2OC4xLjIwOjgwODA.ERERERERERERERERERERERERERERERERERERERERERE", + "boxStaticPublic": "97f74fb8f22ab62e60f9d725835a928ae111775f1cb013375603120c159cb807", + "pairingCode": "0123456789abcdef0123456789abcdef", + "lanHint": "192.168.1.20:8080", + "rendezvousSecret": "1111111111111111111111111111111111111111111111111111111111111111", + "siteId": "deb7e3fa44c17f16", + "fingerprint": "DEB7E3", + "deviceIdOnBox": "l_dPuPIq" + } +} diff --git a/appleApp/FTWKit/Tests/FTWKitTests/Resources/webapp-vectors.json b/appleApp/FTWKit/Tests/FTWKitTests/Resources/webapp-vectors.json new file mode 100644 index 0000000..646e5e6 --- /dev/null +++ b/appleApp/FTWKit/Tests/FTWKitTests/Resources/webapp-vectors.json @@ -0,0 +1,163 @@ +{ + "note": "Generated by generate-vectors.ts from srcfl/ftw-webapp. Do not hand-edit.", + "protocolName": "Noise_IK_25519_ChaChaPoly_SHA256", + "handshake": { + "prologue": "66747731", + "initiatorStatic": "a1a1a1a1a1a1a1a1b2b2b2b2b2b2b2b2c3c3c3c3c3c3c3c3d4d4d4d4d4d4d4d4", + "initiatorStaticPublic": "9cec68f5ec3551bc8d392406bba24d4a6e3d93ddb5c5045dbf5603d56ef0692b", + "initiatorEphemeral": "1111111111111111222222222222222233333333333333334444444444444444", + "responderStatic": "5f5f5f5f5f5f5f5f6060606060606060717171717171717182828282828282f2", + "responderStaticPublic": "97f74fb8f22ab62e60f9d725835a928ae111775f1cb013375603120c159cb807", + "responderEphemeral": "aaaaaaaaaaaaaaaabbbbbbbbbbbbbbbbccccccccccccccccdddddddddddddddd", + "message1Payload": "706169723d51522d38383432", + "message1": "331d3762839c4e57d1e81afc5cabd016cf07fc9c1855455f4b9fb4b6ee88c46f7842572896c354fe4b5c01f3532b5bd0f419fba5a5dead3243a00f2752a4209352461793d03356835ebabb86bb505b47ed99a7103cb61680c06ba42f1a9837b45bb0be95d9c583d52b2a9adc", + "message2Payload": "626f6f743d6f6b", + "message2": "b27d950049dbc37de5c6386f591f76561a5898dec21b8f2569e7a071ee74362cac1663e77c54a86924a0038be6368ebeb67b827b370822", + "handshakeHash": "5eaf9676153d0091f74275f236e9d7ee36f7e86cc448c9b17d4d8558b36cd639" + }, + "frames": [ + { + "name": "tick", + "lane": 0, + "flags": 0, + "bucket": 512, + "payloadLen": 17, + "envelopeKeys": [ + "t", + "b" + ], + "bytes": "010000000011a26174647469636b6162a1637365711829000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000" + }, + { + "name": "tick_no_body", + "lane": 0, + "flags": 0, + "bucket": 512, + "payloadLen": 8, + "envelopeKeys": [ + "t" + ], + "bytes": "010000000008a16174647469636b000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000" + }, + { + "name": "delta_export", + "lane": 0, + "flags": 0, + "bucket": 512, + "payloadLen": 51, + "envelopeKeys": [ + "t", + "b" + ], + "bytes": "010000000033a261746564656c74616162a263736571182a666669656c6473a56132391067613319183861343905db613519036b61361934580000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000" + }, + { + "name": "delta_truncated", + "lane": 0, + "flags": 2, + "bucket": 512, + "payloadLen": 31, + "envelopeKeys": [ + "t", + "b" + ], + "bytes": "01000200001fa261746564656c74616162a263736571182b666669656c6473a16132192c8800000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000" + }, + { + "name": "cmd_with_id", + "lane": 0, + "flags": 0, + "bucket": 512, + "payloadLen": 132, + "envelopeKeys": [ + "t", + "id", + "b" + ], + "bytes": "010000000084a3617463636d64626964076162a565636d644964782430313866336132622d303030302d373030302d383030302d303030303030303030303031626f70687365745f6d6f64656461726773a1646d6f64656469646c656f6e6f7456616c696441667465724d731b0000018eafc1843566657870656374a2637265760c66677561726473800000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000" + }, + { + "name": "unknown_type_with_unknown_keys", + "lane": 0, + "flags": 0, + "bucket": 512, + "payloadLen": 72, + "envelopeKeys": [ + "t", + "b", + "futureField", + "anotherOne" + ], + "bytes": "010000000048a4617467706c616e2e76326162a169686f72697a6f6e4d731a0036ee806b6675747572654669656c647166726f6d2061206e6577657220706565726a616e6f746865724f6e65182a0000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000" + }, + { + "name": "small_control_bucket", + "lane": 0, + "flags": 0, + "bucket": 256, + "payloadLen": 17, + "envelopeKeys": [ + "t", + "b" + ], + "bytes": "010000000011a26174647469636b6162a163736571182c0000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000" + }, + { + "name": "snap_bulk", + "lane": 1, + "flags": 0, + "bucket": 1024, + "payloadLen": 150, + "envelopeKeys": [ + "t", + "id", + "b" + ], + "bytes": "010100000096a3617464736e6170626964036162a4637265760c6464696374a16132a3646e616d6566677269645f7764756e69746157657372634964686d657465722e7031666669656c6473a26132391067613519036b67736f7572636573a1686d657465722e7031a4646b696e64656d65746572686c6173744f6b4d731903846c7374616c6541667465724d73191388657374617465646c69766500000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000" + } + ], + "transport": [ + { + "from": "app", + "frame": "cmd_with_id", + "seq": 0, + "wire": "0000000000000000ecce70a797fdc476b2fb258a1cb01112256de53e61c0efee55970aa183d4641ae5f183679cc24a3c9786e2b75a6a4bf00a734527a2407e0c673476ab444872ea0a3b47403302f746e86af81d30992299c4169d096b7718e2fd370661606c56209e6c080fb1c503ba7eb5ed0ddca6c84899c80a0dc880756336ed544edf839bb48794d1ae51ac7be08ea15eb72835d25f60b4d805720a4aad29803fccd4adc7d6296e742ccee959b97941969daef39f983b3ffbf606809c7c8e61a3ead7fc5c00611a284490a2e01c75dd154c5a98c01932dc1b7d423dbec215110d617598980a832a6db06f11c1f49591337c274614d9d81a7ec20877ac06f5a88d5ab50c531f1adce441d7956b1da86d2fde8b2acc1993afe8ad5f1fa381aec4135651e4648668b7cdf859f9d37f81a0dc2d8025694817cc5532c3bfeed82b9c3288a82c501d1bf7d7a8663e10cbba5c7fd171b3fa9220a3b5e3bf33b78421cac780ed6e99945636e2a9702dd384f64d9dd4322a7ec60577d4dac9da48a0eeaa477793c63b2639ca72d9114fdd0145d17fc1278171d9b0d16a8a8829b17fcc2a6999e16d623f0fdd0fd1e92e56ef3c7885c490520e45a10bc5b716532d3e131dcb694fd49fc47a5291542d8aaba2972bd6a8b4072b11e116c2f98483333231c48783e9ffa5f27dbc3cc2adb1b573ffdfb5375bcb3af3e722e95cf5e1c4ea0e861ae2ba81ab80e8b941f78a982f0f9eae469efa0352c7" + }, + { + "from": "box", + "frame": "tick", + "seq": 0, + "wire": "0000000000000000516e9a6c206bd055dfc435339b7a1e0aadfe361f60c6165d77b5218e9611c197034bd0ad3d5b650ad372bfe47caa9106347c476047a313d07d96134f7d42c4e07d9760467d768cfcfecc7cf398db76365cbbc2fe4d393ba1bf3fea6ea6d54c3f9eb425ed898bbc5426fec1bffefaafac0dae5262212c5595f24baee48aa5e783b6af06c59046f8178c72b8b5ee285c5b18a36f4c9fc9f37a5c1ea8f5e602dbba6787fd094457f9c8954ed2caf8c6b6a2f507f774f4131c32cda6a50d0b1006afb8aec043baf4fbbb6f00ecef1d0794ba92129dd5654633d324f5d7d75027f32ba6856b7dee94bf93df6d16210fa06da0422a09f78d92aac35ee9dca6b3dfd71748e9f245364a945bcca8fa73fe15583b64a8ff0ac01302c93a95e31770b00f1f3f2b68d17554649141dbd3a982fa911083a67126e9206c1947d5389f7273d9152ceab3e9dea5ab809b69eaf5a671fce5425639b2ae2c148d6452fd23a875e5d527c44efd4782ffd57017498c56026b55d2654ca7527b7bc13c30bf82aefcc41318766dd57c4168e158e8ced3b22ca311729ea84444cadac0706e25c9283a393cf825cdadfb5580f77885eb18fce413da50b41ce2714b73fa9e5252070b5e357ac1234fee4af339e764e44664883709c0fdf78ba943f47b72990ac255fb0e3c082889971de295956ef0bdb1ab64e21ba93a9966e0fe1809718b33d36b0d0033e420b237a22bae143e5825baa82ba719c0" + }, + { + "from": "box", + "frame": "delta_export", + "seq": 1, + "wire": "0000000000000001c473f3e7d40222d7d80bcabf50979a162b7fe59c5c98d24ffc5d00fffd4e1d9268c256d2e46db025e56d03b71b79115c8a6070496025d4dfe32d7774bb1d6eafec69e6d5667467e2c4ffb9cb0ddd6f525dd7820f74884c14959abccf166cae9d498e963f690b68b3e8c1ba6c6e4e94ce63aecb816814b35fe4398092e4c15948bfaa866cc6e240d9439e81c87bcd4715b17fc0a705cd15545b461532814483991dd10a535faf52692fbbf1b30957184ab41d913034463d703073a05e115b53c954c6574117ba40576d0b290c54353f8d509b027f5466403428b0a3dbe0ad9a05823dc5167509bce811dae677f91bd61bc814c09ec8696bc71a0f3a568837fb87d23a7accf46cba8967ffdcee5e1f26550e4c212d4919f2a1fab90e2321ed49a4bd2e73ad27ec2f4aad7b7e070279d40bf146584aeb1f0ed3f048f6e613176619541fdd60203ae544ae560cd5b9437f23a10be36392429db47cba2ecca971106cfa0d3186e27d22c85052018b03ac14a0fb5bee0875c6eda5e9bc642d6b79fc4dfca1459c6d3acf101e1eba91871c8b8adc6d6571b28eab0e28d68cfddbd04d1082ee39e8bc465dcc80493423f8b2cfc447659ed11615065524ee8ff8bba783712c75c12e0d5858418ee253321a50d925dcdb2eb7c03863418fdb3312c7eff9094b68f32f74a99d5c2c1acb2413fba2970317f8d18fd67f12e4a85713d193216bacda90c80650cacaf8b46936ca3e9be8" + }, + { + "from": "box", + "frame": "delta_truncated", + "seq": 2, + "wire": "000000000000000254e09b9c87a03a9f6335a3a3ce3af98267f074cfa510300177b81a74643c66abbbbc5f2c4f722de2b6e2599e99ee36c34462c4f89b329ff46a9c2c3494340a175c98ea4f64104413348988ab4175fdad2fcdd07ff018232677323df57cff99e95cd8acc976c8b8360f0530da673fe29eab347ef382569c6a682d53fe0b45d524f9c3ad33fd904788d2b8199beb99fcc6c45c6365f54f507135a1acacb2568e59ef077ca4c9dcf44631b3125ae51f30c072bfafd44de2c765c5c3c3d4ffe02e8aa667dc5cdfbb134089874931ef173ccd4c755627a380bb76e8d72a27d461d4aff617c7765756d9e23323ad8a5168b3496961a35994b0653a5458f9542f1be8363cc4029c76331583bf698b46f8a53c70464c1d360a6411b2663b864ccbc4e8477d01f9ecdb5970e8a0ef822ec6612c7f64a923b7338080a09b92555a96697066c2acd610b1bc06296ccd87e9074c72b6290c37e877e767df7f51ae16648e5f7ce445bb9ae0a9c63c40694bbe18a7b1e661661b0dda5194f5b228af66c799aa1a49018849d32f5db5f218111e35a747c28afe0a11be1648e67a26760d0db19ebd5505fec484186ce55216b87fbcd64d1ba7b0c3035fd5d552e9d5c9ce77c2b6bdec57d49e901643db9da24c1baa4adf0fddc158aaad77610664c8fb8953f5a0882776919d9b1766657259d3530cd251bcb1824c6ac86c778f07daefb39cbb6a5a0e6402dee12aa21f96a6de33de690701" + }, + { + "from": "app", + "frame": "tick_no_body", + "seq": 1, + "wire": "0000000000000001c8591c8434b5950b20b903560ce80df2ba339734d70d6c444ab447dcc9fe10769a9345e7571c4beb39768b7b3eb9b4ff8f72e59aae18d652aa95086065f40c69303d20f7ce0921311a7313cf98fed03f9eb53259855030413b3e6a2a07be66108c6eef709f0ef5e1dd2ee3afc6372859849334e3c95d496d076ad2d06140a40e07cfde154536134a2ad04c4327fabc37fe82ff2e90e435eebf4890a9c53024313d9114c2e2a8fa1e645c697c9616e94aa68fb1a69725687bfbe401b085311e8f833276624ceaea939fe55d5b7b58e7e590befa7f652ccef32cb974a8c289022444a112c7436020235853e642846eeed54edc5046380a3d36c0de8c0cb25e7f16a34c8f9d43df541674de3609cab98c06b644e330b5fa5250a94bc575e5d7b4519dbc7f5ef2cd1da8d8f0a24220886c2bcfbe5d746d4d0290a4682eddf78671f8522bd9cda65bc67d927301c1d0af95797109e2ef54e198d4ab0635a7c85a09cd73d6e43c098032196dfbd471cffb9ac240ab4e76951be14b66811ed4a1a82b46e29c7713d1cf9d02772cf0ddace7b0367c8cc51239f86a0bb968f67c1120dec653505741fcfda16505d9011ea3e63a16ad3ad923bf8eef02e646adf0858d903fc5ad0823becb8280d19ca746dedea43450a03759324a633106529d00de7426e414448b33a1f91f174a39328ad2ac6043b2e633d7f2b87b9e0c5b86cdbbcc5152015c21f8427ace8cd641c1568893724f" + }, + { + "from": "box", + "frame": "snap_bulk", + "seq": 3, + "wire": "0000000000000003b9d298685a72f1ac18509da2a460138293a53aba3017b787583a7a4467d5b87ac9224e8cf9d0a701f75030aa12783defa4523d5cad0b42e489cbd07636077be1bf3ca30704f4a3464822c51ccadc01652d2a064330affc2038e486e1d913a36c2af9366d5214bbdb626340a7c941dd3438b503e60f7e6bbf1ba9079c27df1a08fb93e3d040598129edbd8f52149bdf1a48f3cf3bcdb90655f736f0a83a419662736e8be426fe220edaeeb030369fd0cb7f9c49e586d5b30e31d505434f7de3a960958749a8022002375503a718a5cdc98ca66b582108a99b490a19434bf7157d4c76e1e89d496711ef21780d98922f0ce82ad65db51637734ddbb4375cb63be6d7f9127562b6977edce3fb1cb65556c209ad1a8c2f1fc92a6ab88787ca4f0a45b7cea4da4178ca637c9c76262fba7e64e1b1bab2ef84503e47d03c50f965648f3184fd8c92a4726497fa654f7b44f189176ac51f0fcd33ee3cccaa1f9d57d670041a6941f90154141269430e73f55d7a2b9110a7a0675ceb7386e652ef1a246a8ae34f18583616eb5f330d6ca3fef5bf0e20e4ab3d80c032ef6a382d3b2d8a549893675f6473681daf0ec77367ca6c15c0a971c92b1f5c5567494579c3bead028715dac89dad9dc452516cdacd8d52f5149e8900be9d0693749cb1bdba20bba4cc5c784538f18706c7905171cfa3a72c86f6ac13579fc81f6efac3ee865236eb9993817e518194b410aae58df235bfe991504569556e74894fb1480968247403f34691426a973db3a080124bae7eef9ee8c27d0ed07bcda69875fd49e524606f9e2890133de2d1522421ef96ee2f8a888d8b5927018860d36a91765508b25bf8d537a3b6a31086c3de2c4417221c015815330367732317acc63d8e34acb6965cbfd8957745197ac70779b9f7acd5e3824f81961af2f499f06ac4522f1427419174bc9f36810fe6ad15dcb68d9560e01e269408405c104633009b060a188bbb92efb86fb7f0f45d43b191e571d603581d267df57b85fb5c0f08ee87f8087b5e9ebd2dd342d9b4662f6665bea8290c867d161f95969a49a721e8a1846ab3cf381468ba1eee407482770644d2d3e04264531e5189666250ce72171a7372badb03336001da905f8ad453363470a7fbe553cb63a8c4783b51fc3fab2181c9f125829bcc27886bb9696aab59ca17ed3690ce19b9f5db052736798ebd54eac089de1a3e95512df4515b78914ab48c300e0233d5aaeae2eb1ecb91bb4abfda79fa610d1eaa28f9e9e6a65c2b648f9f0738782adf7354a647088908d5a285b7dc9368a2af4203390a6e70cbbb4a7fb7d6c4dd141121f716a0fd86e17ff082553c6e0fb8a15dd5e9f768c73f453e45c76949dbb9adbc241e50da70f9274516d92613379bb038cda8cb61e9db8ebbbf905107279caf60b9583df2de6b0164912c2733f0a1c7e1e39a049ad61a04b24d41faffcc6038" + }, + { + "from": "box", + "frame": "tick", + "seq": 4, + "wire": "000000000000000424ecfa4e44db2a323fef3473988ed660820a5596a96cb2801740c3d06e366fd1953597421d3f4ea000b74f699639f0b2fc16c94def7899b25b057dccce7ab1e0ff2ddafd9b83139f2e4c6fc7077ac961a413bbcdc4831cbb3c4670ee8ca6f9d3cb3b9f48a77fe0037ceb883a670c732f11e5e5ca9b4ffb99c18233578b71192e9103bea3e358e118d8624665b23dbd021b93d361985cf0ae3af9224450741627a1175b450f15ce2b5282e36fcfd2e132584356217b744b1312f13088fbe2f38177a72d9d5179a77e4f930787aab842a87d1bc277d34334446e0dfb6961d5a252d9d0888290a6a2d0d73020b6c425309a8f87eac9d0bb42346e9d963eedf267e05e88658b24cb43404dfed2454d22be9ffdb0ec371c27a1949407273d713bde52d8d946be18531ef3af68d27c08e2731d50f98020c02e80c082c4fa2bd6a5dd5593bf005f521b6cc4772c2a59a0580a82f5dd915248a641cb4662af4ccadf8c728ea60a08e4383a91adfefc425fa4430ba27a51cc87a2628a4ce4e25d54ee2ff37abdd7fb408155987332de4271e06e24a33b3a1e24c4e84e424c5bb6f56aa4c7629c764f3df9e0966a2c31634fce4ff5bfec91744cd90159a390d935cc9d0f1943dfe177d7489173b98893f8716a55a03cfc10494129513f4273633446a2a570755156e62361ff89fbe0f7c6f6ba92ab59382ea42d774979c24e7caa8f68cb95716078fa5e7ee43963f501fe8edf896d" + } + ] +} diff --git a/appleApp/FTWKit/Tests/FTWKitTests/SessionTests.swift b/appleApp/FTWKit/Tests/FTWKitTests/SessionTests.swift new file mode 100644 index 0000000..f0b3f5b --- /dev/null +++ b/appleApp/FTWKit/Tests/FTWKitTests/SessionTests.swift @@ -0,0 +1,369 @@ +import Foundation +import Testing +@testable import FTWKit + +@MainActor +@Suite struct SessionTests { + @Test func theWholeStackReachesAStream() async throws { + let rig = Rig() + await rig.run(200) + #expect(rig.session.state.phase == .streaming) + #expect(rig.session.state.carrier == .relay) + #expect(rig.session.state.heardFromBox) + #expect(rig.session.state.caps.contains(Contract.capAPIPassthrough)) + #expect(rig.session.state.fields[Contract.FID.gridW] != nil) + #expect(rig.session.state.modes.first?.key == "planner_passive_arbitrage") + // The pairing code travelled, encrypted, in message 1. + #expect(rig.endpoint.handshakePayloads.first == rig.pairingCode) + + // The relay only ever saw the rotating handle, never a key. + let url = try #require(rig.relay.dialled.first) + let parts = url.path.split(separator: "/") + #expect(parts.count == 4) + #expect(parts[0] == "r") + #expect(parts[3] == "app") + #expect(String(parts[2]) == (try Rendezvous.handle(secret: Bytes(repeating: 9, count: 32), epoch: Int64(parts[1])!))) + } + + @Test func laneZeroFramesAreOneSizeOnTheWire() async throws { + let rig = Rig() + await rig.run(5_000, step: 100) + // Everything the app sent after the handshake on lane 0: hello, sub. + let sent = try #require(rig.relay.current?.sent) + let transport = sent.dropFirst() + #expect(!transport.isEmpty) + #expect(Set(transport.map(\.count)) == [512 + NoiseTransport.overhead]) + } + + @Test func telemetryKeepsArriving() async { + let rig = Rig() + await rig.run(200) + let before = rig.session.state.uptimeMs + await rig.run(3_000, step: 100) + #expect(rig.session.state.uptimeMs > before) + } + + @Test func theBoxsOwnAPIAnswersInJSON() async throws { + let rig = Rig() + await rig.run(200) + let task = Task { try await rig.session.api(APIRequest(method: .get, path: "/api/status")) } + await rig.run(200) + let response = try await task.value + #expect(response.status == 200) + let json = try JSON(parsing: response.body) + #expect(json["fuse"]?["max_amps"]?.number == 25) + } + + @Test func aLargeAnswerArrivesInOrderedChunks() async throws { + let rig = Rig() + await rig.run(200) + // Ninety days of ledger is several chunks. + let task = Task { try await rig.session.api(APIRequest(method: .get, path: "/api/energy/daily", query: ["days": "90"])) } + await rig.run(300) + let response = try await task.value + #expect(response.body.count > 12_288) + #expect(try JSON(parsing: response.body)["days"]?.array?.count == 90) + } + + @Test func refusalsArriveAsCodesAndNoHandlerRan() async throws { + let rig = Rig(requireStepUp: true) + await rig.run(200) + for (req, code) in [ + (APIRequest(method: .post, path: "/api/restart"), "E_NEEDS_STEP_UP"), + (APIRequest(method: .post, path: "/api/mode"), "E_USE_CMD"), + (APIRequest(method: .get, path: "/api/config"), "E_LOCAL_ONLY"), + (APIRequest(method: .get, path: "/api/nothing/here"), "E_UNKNOWN_OP"), + ] { + let task = Task { try await rig.session.api(req) } + await rig.run(100) + do { + _ = try await task.value + Issue.record("\(req.path) answered") + } catch let refusal as BoxRefusal { + #expect(refusal.detail.code == code) + } + } + // The same request with the flag goes through. + let task = Task { try await rig.session.api(APIRequest(method: .post, path: "/api/restart", stepUp: true)) } + await rig.run(100) + #expect(try await task.value.status == 200) + } + + @Test func oneAPICallIsOnTheWireAtATime() async throws { + let rig = Rig() + await rig.run(200) + let a = Task { try await rig.session.api(APIRequest(method: .get, path: "/api/status")) } + let b = Task { try await rig.session.api(APIRequest(method: .get, path: "/api/loadpoints")) } + await rig.run(400) + #expect(try await a.value.status == 200) + #expect(try await b.value.status == 200) + } + + @Test func aModeCommandIsAckedAppliedAndReplans() async throws { + let rig = Rig() + await rig.run(200) + let task = Task { try await rig.session.command(Contract.opSetMode, args: [("mode", .text("self_consumption"))]) } + await rig.run(200) + let result = try await task.value + #expect(result.state == .applied) + #expect(result.observedValue == 3) + // The plan pushed unasked lands on state. + #expect(rig.session.state.plan != nil) + await rig.run(1_500, step: 100) + #expect(rig.session.state.fields[Contract.FID.mode] == 3) + } + + @Test func noAckMeansItNeverReachedTheBox() async throws { + let rig = Rig() + await rig.run(200) + rig.box.mute = true + let task = Task { try await rig.session.command(Contract.opSetMode, args: [("mode", .text("idle"))]) } + await rig.run(Session.cmdAckTimeoutMs + 100, step: 100) + do { + _ = try await task.value + Issue.record("a silent box confirmed") + } catch let e as CommandError { + #expect(e.code == "E_NO_ACK") + } + } + + @Test func ackedButNeverConfirmedIsItsOwnAnswer() async throws { + let rig = Rig() + await rig.run(200) + rig.box.neverConfirm = true + let task = Task { try await rig.session.command(Contract.opSetMode, args: [("mode", .text("idle"))]) } + await rig.run(Session.cmdConfirmTimeoutMs + 200, step: 100) + #expect(try await task.value.state == .unconfirmed) + } + + @Test func aDropSettlesEveryRequestInFlight() async throws { + let rig = Rig() + await rig.run(200) + rig.box.mute = true + let task = Task { try await rig.session.plan() } + await rig.run(50) + rig.relay.closeCurrent(code: 1006, reason: "") + await rig.run(20) + await #expect(throws: SessionError.carrierClosed) { _ = try await task.value } + #expect(rig.session.state.phase == .failed) + // Readings stay, older. + #expect(!rig.session.state.fields.isEmpty) + } + + @Test func theSessionComesBackAfterADrop() async throws { + let rig = Rig() + await rig.run(200) + rig.relay.closeCurrent(code: 1006, reason: "") + // Full-jitter backoff at the scheduler's 0.5: 250 ms, then a new + // socket, a new handshake and a new stream. + rig.endpoint.dropSession() + await rig.run(1_000, step: 20) + #expect(rig.session.state.phase == .streaming) + #expect(rig.relay.dialled.count == 2) + } + + @Test func theBoxLeavingAndReturningKeepsOneSocket() async throws { + let rig = Rig() + await rig.run(200) + rig.relay.boxLeaves() + await rig.run(20) + #expect(rig.session.state.phase == .failed) + rig.relay.boxReturns() + await rig.run(300) + #expect(rig.session.state.phase == .streaming) + #expect(rig.relay.dialled.count == 1) + } + + @Test func aRelayEpochCorrectionIsTakenOnlyWhenSmall() async throws { + let rig = Rig() + await rig.run(200) + let ours = Rendezvous.epoch(nowMs: rig.scheduler.nowMs) + rig.relay.closeCurrent(code: RelayCarrier.closeEpoch, reason: String(ours + 1)) + await rig.run(100) + let corrected = try #require(rig.relay.dialled.last) + #expect(corrected.path.split(separator: "/")[1] == Substring(String(ours + 1))) + + // A relay naming an epoch a year away is not a clock correction. + rig.relay.closeCurrent(code: RelayCarrier.closeEpoch, reason: String(ours + 9_000)) + await rig.run(100) + let refused = try #require(rig.relay.dialled.last) + #expect(refused.path.split(separator: "/")[1] == Substring(String(ours + 1))) + } + + @Test func aSilentBoxEndsTheHandshakeAndItIsRetried() async throws { + let rig = Rig(connect: false) + rig.endpoint.refuse = true + rig.session.connect(rig.noise) + await rig.run(NoiseCarrier.handshakeDeadlineMs + 100, step: 200) + if case .closed(let reason, let retryable) = rig.noise.status { + #expect(reason == "the box did not answer") + #expect(retryable) + } else { + Issue.record("still \(rig.noise.status)") + } + rig.endpoint.refuse = false + await rig.run(20_000, step: 200) + #expect(rig.session.state.phase == .streaming) + } + + @Test func aForeignFrameOnTheRoomIsIgnored() async throws { + let rig = Rig() + await rig.run(200) + rig.relay.current?.deliver(randomBytes(528)) + rig.relay.current?.deliver(randomBytes(48)) + await rig.run(1_500, step: 100) + #expect(rig.session.state.phase == .streaming) + } + + @Test func aStartingBoxIsAskedAgainOnItsOwnTimer() async throws { + let rig = Rig(connect: false) + rig.box.booting = true + rig.session.connect(rig.noise) + await rig.run(200) + #expect(rig.session.state.phase == .booting) + #expect(rig.session.state.boot?.phase == "vacuum") + rig.box.booting = false + await rig.run(Session.bootRetryMs + 200, step: 100) + #expect(rig.session.state.phase == .streaming) + } + + @Test func historyArrivesAsTilesAndAssembles() async throws { + let rig = Rig() + await rig.run(200) + let to = rig.scheduler.nowMs + let from = to - 86_400_000 + var chunks = [String: HistChunk]() + let task = Task { + try await rig.session.history(HistQuery(series: ["grid_w", "pv_w", "battery_w", "load_w"], res: .fiveMinutes, fromMs: from, toMs: to, have: [], maxPoints: 1500)) { chunk in + chunks[chunk.tileId] = chunk + } + } + await rig.run(300) + let end = try await task.value + #expect(end.resActual == .fiveMinutes) + let plan = HistoryGeometry.plan(.fiveMinutes, fromMs: from, toMs: to, maxPoints: 1500) + #expect(Set(chunks.keys) == Set(plan.tiles.map(\.tileId))) + let frame = HistoryGeometry.clip(HistoryGeometry.assemble(plan, names: ["grid_w", "pv_w", "battery_w", "load_w"], tiles: chunks), fromMs: from, toMs: to) + #expect(frame.points >= 287 && frame.points <= 289) + #expect(frame.value("load_w", at: 0) != nil) + } + + @Test func timesGoOutAsWholeMilliseconds() async throws { + // A real box drops a query whose times carry a fraction, and a + // phone's clock has one. Found against a live box. + let rig = Rig() + await rig.run(200) + let at = rig.scheduler.nowMs + 0.25 + let task = Task { try await rig.session.prices(fromMs: at, toMs: at + 3_600_000.5) } + await rig.run(200) + #expect(try await task.value.slots.isEmpty == false) + } + + @Test func pricesAndThePlanAreAsked() async throws { + let rig = Rig() + await rig.run(200) + let plan = Task { try await rig.session.plan() } + let prices = Task { try await rig.session.prices(fromMs: rig.scheduler.nowMs, toMs: rig.scheduler.nowMs + 86_400_000) } + await rig.run(200) + #expect(try await plan.value.slots.count == 96) + #expect(try await prices.value.currency == "SEK") + } + + @Test func revocationTerminatesAndSaysWhy() async throws { + let rig = Rig() + await rig.run(200) + let frame = try Frame.encodeBulk(envelope: Envelope(t: "session.terminate", b: .map([("reason", .text("revoked"))]))) + rig.box.send?(frame) + await rig.run(50) + #expect(rig.session.state.phase == .terminated) + #expect(rig.session.state.terminated == .revoked) + } + + @Test func cachedReadingsNeverOverwriteALiveCarrier() async throws { + let rig = Rig() + // Until the handshake to the box completes, the cache is honestly + // what is on screen. + var old = SessionState() + old.fields = [Contract.FID.gridW: 1_234] + for _ in 0..<20 where rig.session.state.phase != .handshaking { + await rig.run(5, step: 5) + } + #expect(rig.session.state.phase == .handshaking) + // Mid-handshake: the cache paints data, never the phase or carrier. + rig.session.restore(CachedSnapshot(siteId: "s", savedAtMs: 0, state: old)) + #expect(rig.session.state.phase == .handshaking) + #expect(rig.session.state.carrier == .relay) + #expect(rig.session.state.fields[Contract.FID.gridW] == 1_234) + await rig.run(200) + #expect(rig.session.state.phase == .streaming) + rig.session.restore(CachedSnapshot(siteId: "s", savedAtMs: 0, state: old)) + #expect(rig.session.state.fields[Contract.FID.gridW] != 1_234) + } +} + +@Suite struct HistoryGeometryTests { + @Test func wideWindowsClampToTheHourlyStore() { + let now = 1_750_000_000_000.0 + let year = HistoryGeometry.plan(.fiveMinutes, fromMs: now - 365 * 86_400_000, toMs: now, maxPoints: 1500) + #expect(year.res == .hour) + #expect(year.tiles.count * year.tiles[0].points <= 1500 * 2) + let day = HistoryGeometry.plan(.fiveMinutes, fromMs: now - 86_400_000, toMs: now, maxPoints: 1500) + #expect(day.res == .fiveMinutes) + #expect(day.stride == 1) + } + + @Test func packingRoundTripsAndKeepsTheMissingMarker() { + let columns: [[Int32]] = [[1, -2, missingSample], [Int32.max, 0, 7]] + #expect(HistoryGeometry.unpack(HistoryGeometry.pack(columns), seriesCount: 2) == columns) + } + + @Test func etagIsFNV1a() { + #expect(HistoryGeometry.etag([]) == "811c9dc5") + #expect(HistoryGeometry.etag(Array("a".utf8)) == "e40c292c") + } +} + +@Suite struct ContractTests { + /// The registry is one file shared with the box and the web app. Every + /// name this app spells has to be the registry's. + @Test func namesMatchTheSharedRegistry() throws { + let yaml = try RepoFiles.read("protocol/registry.yaml") + func names(section: String, key: String) -> [String] { + var out = [String]() + var inside = false + for line in yaml.split(separator: "\n", omittingEmptySubsequences: false) { + if line.hasPrefix("\(section):") { inside = true; continue } + if inside, let first = line.first, !first.isWhitespace, first != "#" { break } + guard inside, let range = line.range(of: "\(key): ") else { continue } + let rest = line[range.upperBound...] + out.append(String(rest.prefix { $0 != "," && $0 != "}" && $0 != " " })) + } + return out + } + func list(section: String) -> [String] { + var out = [String]() + var inside = false + for line in yaml.split(separator: "\n", omittingEmptySubsequences: false) { + if line.hasPrefix("\(section):") { inside = true; continue } + if inside, let first = line.first, !first.isWhitespace, first != "#" { break } + let t = line.trimmingCharacters(in: .whitespaces) + if inside, t.hasPrefix("- ") { out.append(String(t.dropFirst(2))) } + } + return out + } + + #expect(names(section: "scopes", key: "name") == Contract.scopes) + #expect(names(section: "ops", key: "name") == Contract.ops) + #expect(list(section: "capabilities") == Contract.capabilities) + let errors = names(section: "errors", key: "code") + names(section: "client_errors", key: "code") + #expect(Set(errors) == Set(Contract.retryable.keys)) + for code in errors { + let line = yaml.split(separator: "\n").first { $0.contains("code: \(code),") }! + #expect(line.contains("retryable: \(Contract.isRetryable(code))"), "\(code)") + } + #expect(yaml.contains("source_states: [\(Contract.sourceStates.joined(separator: ", "))]")) + #expect(yaml.contains("carrier_states: [\(Contract.carrierStates.joined(separator: ", "))]")) + #expect(yaml.contains("1: { name: mode,")) + #expect(yaml.contains("10: { name: ev_w,")) + } +} diff --git a/appleApp/FTWKit/Tests/FTWKitTests/Support.swift b/appleApp/FTWKit/Tests/FTWKitTests/Support.swift new file mode 100644 index 0000000..3596d26 --- /dev/null +++ b/appleApp/FTWKit/Tests/FTWKitTests/Support.swift @@ -0,0 +1,69 @@ +import Foundation +@testable import FTWKit + +/// The shared interop vectors, generated by srcfl/ftw from the web app's own +/// code (go/internal/appwire/testdata/generate-vectors.ts). The Go box replays +/// the same file; replaying it here puts three implementations on one wire. +struct InteropVectors: Decodable { + struct Handshake: Decodable { + let prologue: String + let initiatorStatic: String + let initiatorStaticPublic: String + let initiatorEphemeral: String + let responderStatic: String + let responderStaticPublic: String + let responderEphemeral: String + let message1Payload: String + let message1: String + let message2Payload: String + let message2: String + let handshakeHash: String + } + + struct FrameVector: Decodable { + let name: String + let lane: UInt8 + let flags: UInt8 + let bucket: Int + let payloadLen: Int + let envelopeKeys: [String] + let bytes: String + } + + struct TransportStep: Decodable { + let from: String + let frame: String + let seq: UInt64 + let wire: String + } + + let protocolName: String + let handshake: Handshake + let frames: [FrameVector] + let transport: [TransportStep] + + static func load() throws -> InteropVectors { + guard let url = Bundle.module.url(forResource: "webapp-vectors", withExtension: "json", subdirectory: "Resources") else { + throw CocoaError(.fileNoSuchFile) + } + return try JSONDecoder().decode(InteropVectors.self, from: Data(contentsOf: url)) + } +} + +/// Where the repository's shared files live, found from this file's path so +/// the tests read the real registry rather than a copy of it. +enum RepoFiles { + static var root: URL { + URL(fileURLWithPath: #filePath) + .deletingLastPathComponent() // FTWKitTests + .deletingLastPathComponent() // Tests + .deletingLastPathComponent() // FTWKit + .deletingLastPathComponent() // appleApp + .deletingLastPathComponent() // repository + } + + static func read(_ path: String) throws -> String { + try String(contentsOf: root.appendingPathComponent(path), encoding: .utf8) + } +} + diff --git a/appleApp/scripts/demo-screenshots.sh b/appleApp/scripts/demo-screenshots.sh new file mode 100755 index 0000000..3ebc1b3 --- /dev/null +++ b/appleApp/scripts/demo-screenshots.sh @@ -0,0 +1,37 @@ +#!/usr/bin/env bash +# Photographs every screen of the demo in an iOS Simulator, for review. +# Needs a Debug build in build/ios, as the Apple workflow makes: +# xcodebuild build -project FTW.xcodeproj -scheme FTW \ +# -destination 'generic/platform=iOS Simulator' -derivedDataPath build/ios +set -euo pipefail +cd "$(dirname "$0")/.." +app=build/ios/Build/Products/Debug-iphonesimulator/FTW.app +out=${1:-build/screenshots} +mkdir -p "$out" + +udid=$(xcrun simctl list devices available --json | python3 -c ' +import json, sys +devices = json.load(sys.stdin)["devices"] +phones = [d for runtime, ds in sorted(devices.items()) if "iOS" in runtime for d in ds if d["name"].startswith("iPhone")] +print(phones[-1]["udid"])') + +xcrun simctl boot "$udid" 2>/dev/null || true +xcrun simctl bootstatus "$udid" -b +xcrun simctl install "$udid" "$app" + +# name, appearance, then the app's launch arguments +shoot() { + local name=$1 look=$2 + shift 2 + xcrun simctl ui "$udid" appearance "$look" + xcrun simctl launch --terminate-running-process "$udid" energy.ftw.app "$@" >/dev/null + sleep 8 + xcrun simctl io "$udid" screenshot "$out/$name.png" >/dev/null + echo "$out/$name.png" +} + +shoot pair dark +for tab in now plan history box; do + shoot "$tab" dark -FTWDemo YES -FTWTab "$tab" +done +shoot now-light light -FTWDemo YES -FTWTab now diff --git a/iosApp/iosApp.xcodeproj/project.pbxproj b/iosApp/iosApp.xcodeproj/project.pbxproj deleted file mode 100644 index 152027e..0000000 --- a/iosApp/iosApp.xcodeproj/project.pbxproj +++ /dev/null @@ -1,296 +0,0 @@ -// !$*UTF8*$! -{ - archiveVersion = 1; - classes = { - }; - objectVersion = 56; - objects = { - -/* Begin PBXBuildFile section */ - A10000000000000000000020 /* FTWApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = A10000000000000000000010 /* FTWApp.swift */; }; - A10000000000000000000021 /* AppModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = A10000000000000000000011 /* AppModel.swift */; }; - A10000000000000000000022 /* PairView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A10000000000000000000012 /* PairView.swift */; }; - A10000000000000000000023 /* NowView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A10000000000000000000013 /* NowView.swift */; }; - A10000000000000000000024 /* QRScanner.swift in Sources */ = {isa = PBXBuildFile; fileRef = A10000000000000000000014 /* QRScanner.swift */; }; - A10000000000000000000025 /* PasskeyHost.swift in Sources */ = {isa = PBXBuildFile; fileRef = A10000000000000000000015 /* PasskeyHost.swift */; }; - A10000000000000000000026 /* KeychainStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = A10000000000000000000018 /* KeychainStore.swift */; }; - A10000000000000000000093 /* LICENSE in Resources */ = {isa = PBXBuildFile; fileRef = A10000000000000000000090 /* LICENSE */; }; - A10000000000000000000094 /* NOTICE in Resources */ = {isa = PBXBuildFile; fileRef = A10000000000000000000091 /* NOTICE */; }; - A10000000000000000000095 /* LICENSING.md in Resources */ = {isa = PBXBuildFile; fileRef = A10000000000000000000092 /* LICENSING.md */; }; -/* End PBXBuildFile section */ - -/* Begin PBXFileReference section */ - A10000000000000000000010 /* FTWApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FTWApp.swift; sourceTree = ""; }; - A10000000000000000000011 /* AppModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppModel.swift; sourceTree = ""; }; - A10000000000000000000012 /* PairView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PairView.swift; sourceTree = ""; }; - A10000000000000000000013 /* NowView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NowView.swift; sourceTree = ""; }; - A10000000000000000000014 /* QRScanner.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = QRScanner.swift; sourceTree = ""; }; - A10000000000000000000015 /* PasskeyHost.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PasskeyHost.swift; sourceTree = ""; }; - A10000000000000000000018 /* KeychainStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = KeychainStore.swift; sourceTree = ""; }; - A10000000000000000000016 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; - A10000000000000000000017 /* iosApp.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = iosApp.entitlements; sourceTree = ""; }; - A10000000000000000000081 /* FTW.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = FTW.app; sourceTree = BUILT_PRODUCTS_DIR; }; - A10000000000000000000090 /* LICENSE */ = {isa = PBXFileReference; lastKnownFileType = text; name = "LICENSE"; path = "../LICENSE"; sourceTree = SOURCE_ROOT; }; - A10000000000000000000091 /* NOTICE */ = {isa = PBXFileReference; lastKnownFileType = text; name = "NOTICE"; path = "../NOTICE"; sourceTree = SOURCE_ROOT; }; - A10000000000000000000092 /* LICENSING.md */ = {isa = PBXFileReference; lastKnownFileType = text; name = "LICENSING.md"; path = "../LICENSING.md"; sourceTree = SOURCE_ROOT; }; -/* End PBXFileReference section */ - -/* Begin PBXFrameworksBuildPhase section */ - A10000000000000000000031 /* Frameworks */ = { - isa = PBXFrameworksBuildPhase; - buildActionMask = 2147483647; - files = ( - ); - runOnlyForDeploymentPostprocessing = 0; - }; -/* End PBXFrameworksBuildPhase section */ - -/* Begin PBXGroup section */ - A10000000000000000000002 = { - isa = PBXGroup; - children = ( - A10000000000000000000003 /* iosApp */, - A10000000000000000000080 /* Products */, - ); - sourceTree = ""; - }; - A10000000000000000000003 /* iosApp */ = { - isa = PBXGroup; - children = ( - A10000000000000000000010 /* FTWApp.swift */, - A10000000000000000000011 /* AppModel.swift */, - A10000000000000000000012 /* PairView.swift */, - A10000000000000000000013 /* NowView.swift */, - A10000000000000000000014 /* QRScanner.swift */, - A10000000000000000000015 /* PasskeyHost.swift */, - A10000000000000000000018 /* KeychainStore.swift */, - A10000000000000000000016 /* Info.plist */, - A10000000000000000000017 /* iosApp.entitlements */, - ); - path = iosApp; - sourceTree = ""; - }; - A10000000000000000000080 /* Products */ = { - isa = PBXGroup; - children = ( - A10000000000000000000081 /* FTW.app */, - ); - name = Products; - sourceTree = ""; - }; -/* End PBXGroup section */ - -/* Begin PBXNativeTarget section */ - A10000000000000000000040 /* iosApp */ = { - isa = PBXNativeTarget; - buildConfigurationList = A10000000000000000000071 /* Build configuration list for PBXNativeTarget "iosApp" */; - buildPhases = ( - A10000000000000000000033 /* Compile Kotlin Framework */, - A10000000000000000000030 /* Sources */, - A10000000000000000000031 /* Frameworks */, - A10000000000000000000032 /* Resources */, - ); - buildRules = ( - ); - dependencies = ( - ); - name = iosApp; - productName = FTW; - productReference = A10000000000000000000081 /* FTW.app */; - productType = "com.apple.product-type.application"; - }; -/* End PBXNativeTarget section */ - -/* Begin PBXProject section */ - A10000000000000000000001 /* Project object */ = { - isa = PBXProject; - attributes = { - BuildIndependentTargetsInParallel = 1; - LastSwiftUpdateCheck = 1600; - LastUpgradeCheck = 1600; - TargetAttributes = { - A10000000000000000000040 = { - CreatedOnToolsVersion = 16.0; - }; - }; - }; - buildConfigurationList = A10000000000000000000070 /* Build configuration list for PBXProject "iosApp" */; - compatibilityVersion = "Xcode 14.0"; - developmentRegion = en; - hasScannedForEncodings = 0; - knownRegions = ( - en, - Base, - ); - mainGroup = A10000000000000000000002; - productRefGroup = A10000000000000000000080 /* Products */; - projectDirPath = ""; - projectRoot = ""; - targets = ( - A10000000000000000000040 /* iosApp */, - ); - }; -/* End PBXProject section */ - -/* Begin PBXResourcesBuildPhase section */ - A10000000000000000000032 /* Resources */ = { - isa = PBXResourcesBuildPhase; - buildActionMask = 2147483647; - files = ( - A10000000000000000000093 /* LICENSE in Resources */, - A10000000000000000000094 /* NOTICE in Resources */, - A10000000000000000000095 /* LICENSING.md in Resources */, - ); - runOnlyForDeploymentPostprocessing = 0; - }; -/* End PBXResourcesBuildPhase section */ - -/* Begin PBXShellScriptBuildPhase section */ - A10000000000000000000033 /* Compile Kotlin Framework */ = { - isa = PBXShellScriptBuildPhase; - alwaysOutOfDate = 1; - buildActionMask = 2147483647; - files = ( - ); - inputFileListPaths = ( - ); - inputPaths = ( - ); - name = "Compile Kotlin Framework"; - outputFileListPaths = ( - ); - outputPaths = ( - ); - runOnlyForDeploymentPostprocessing = 0; - shellPath = /bin/sh; - shellScript = "if [ \"YES\" = \"$OVERRIDE_KOTLIN_BUILD_IDE_SUPPORTED\" ]; then\n echo \"Skipping Gradle build from IDE\"\n exit 0\nfi\ncd \"$SRCROOT/..\"\nexport JAVA_HOME=\"${JAVA_HOME:-$(/usr/libexec/java_home 2>/dev/null)}\"\nif [ -z \"$JAVA_HOME\" ] || [ ! -x \"$JAVA_HOME/bin/java\" ]; then\n export JAVA_HOME=\"/opt/homebrew/opt/openjdk@21/libexec/openjdk.jdk/Contents/Home\"\nfi\nexport ANDROID_HOME=\"${ANDROID_HOME:-$HOME/Android/sdk}\"\nexport ANDROID_SDK_ROOT=\"$ANDROID_HOME\"\n./gradlew :shared:embedAndSignAppleFrameworkForXcode\n"; - }; -/* End PBXShellScriptBuildPhase section */ - -/* Begin PBXSourcesBuildPhase section */ - A10000000000000000000030 /* Sources */ = { - isa = PBXSourcesBuildPhase; - buildActionMask = 2147483647; - files = ( - A10000000000000000000020 /* FTWApp.swift in Sources */, - A10000000000000000000021 /* AppModel.swift in Sources */, - A10000000000000000000022 /* PairView.swift in Sources */, - A10000000000000000000023 /* NowView.swift in Sources */, - A10000000000000000000024 /* QRScanner.swift in Sources */, - A10000000000000000000025 /* PasskeyHost.swift in Sources */, - A10000000000000000000026 /* KeychainStore.swift in Sources */, - ); - runOnlyForDeploymentPostprocessing = 0; - }; -/* End PBXSourcesBuildPhase section */ - -/* Begin XCBuildConfiguration section */ - A10000000000000000000050 /* Debug */ = { - isa = XCBuildConfiguration; - buildSettings = { - ALWAYS_SEARCH_USER_PATHS = NO; - CLANG_ENABLE_MODULES = YES; - ENABLE_USER_SCRIPT_SANDBOXING = NO; - IPHONEOS_DEPLOYMENT_TARGET = 18.0; - SDKROOT = iphoneos; - SWIFT_VERSION = 5.0; - }; - name = Debug; - }; - A10000000000000000000051 /* Release */ = { - isa = XCBuildConfiguration; - buildSettings = { - ALWAYS_SEARCH_USER_PATHS = NO; - CLANG_ENABLE_MODULES = YES; - ENABLE_USER_SCRIPT_SANDBOXING = NO; - IPHONEOS_DEPLOYMENT_TARGET = 18.0; - SDKROOT = iphoneos; - SWIFT_VERSION = 5.0; - }; - name = Release; - }; - A10000000000000000000060 /* Debug */ = { - isa = XCBuildConfiguration; - buildSettings = { - ASSETCATALOG_COMPILER_APPICON_NAME = ""; - CODE_SIGN_ENTITLEMENTS = iosApp/iosApp.entitlements; - CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 1; - ENABLE_PREVIEWS = YES; - FRAMEWORK_SEARCH_PATHS = "$(SRCROOT)/../shared/build/xcode-frameworks/$(CONFIGURATION)/$(SDK_NAME)"; - GENERATE_INFOPLIST_FILE = NO; - INFOPLIST_FILE = iosApp/Info.plist; - IPHONEOS_DEPLOYMENT_TARGET = 18.0; - LD_RUNPATH_SEARCH_PATHS = ( - "$(inherited)", - "@executable_path/Frameworks", - ); - MARKETING_VERSION = 0.1.0; - OTHER_LDFLAGS = ( - "$(inherited)", - "-framework", - Shared, - ); - PRODUCT_BUNDLE_IDENTIFIER = energy.ftw.app; - PRODUCT_NAME = FTW; - SWIFT_EMIT_LOC_STRINGS = YES; - SWIFT_VERSION = 5.0; - TARGETED_DEVICE_FAMILY = "1,2"; - }; - name = Debug; - }; - A10000000000000000000061 /* Release */ = { - isa = XCBuildConfiguration; - buildSettings = { - ASSETCATALOG_COMPILER_APPICON_NAME = ""; - CODE_SIGN_ENTITLEMENTS = iosApp/iosApp.entitlements; - CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 1; - ENABLE_PREVIEWS = YES; - FRAMEWORK_SEARCH_PATHS = "$(SRCROOT)/../shared/build/xcode-frameworks/$(CONFIGURATION)/$(SDK_NAME)"; - GENERATE_INFOPLIST_FILE = NO; - INFOPLIST_FILE = iosApp/Info.plist; - IPHONEOS_DEPLOYMENT_TARGET = 18.0; - LD_RUNPATH_SEARCH_PATHS = ( - "$(inherited)", - "@executable_path/Frameworks", - ); - MARKETING_VERSION = 0.1.0; - OTHER_LDFLAGS = ( - "$(inherited)", - "-framework", - Shared, - ); - PRODUCT_BUNDLE_IDENTIFIER = energy.ftw.app; - PRODUCT_NAME = FTW; - SWIFT_EMIT_LOC_STRINGS = YES; - SWIFT_VERSION = 5.0; - TARGETED_DEVICE_FAMILY = "1,2"; - }; - name = Release; - }; -/* End XCBuildConfiguration section */ - -/* Begin XCConfigurationList section */ - A10000000000000000000070 /* Build configuration list for PBXProject "iosApp" */ = { - isa = XCConfigurationList; - buildConfigurations = ( - A10000000000000000000050 /* Debug */, - A10000000000000000000051 /* Release */, - ); - defaultConfigurationIsVisible = 0; - defaultConfigurationName = Release; - }; - A10000000000000000000071 /* Build configuration list for PBXNativeTarget "iosApp" */ = { - isa = XCConfigurationList; - buildConfigurations = ( - A10000000000000000000060 /* Debug */, - A10000000000000000000061 /* Release */, - ); - defaultConfigurationIsVisible = 0; - defaultConfigurationName = Release; - }; -/* End XCConfigurationList section */ - }; - rootObject = A10000000000000000000001 /* Project object */; -} diff --git a/iosApp/iosApp/AppModel.swift b/iosApp/iosApp/AppModel.swift deleted file mode 100644 index 0abdf2c..0000000 --- a/iosApp/iosApp/AppModel.swift +++ /dev/null @@ -1,136 +0,0 @@ -import SwiftUI -import Shared - -@MainActor -final class AppModel: ObservableObject { - @Published var site: PairedSite? - @Published var headline: String = "Waiting for the first reading." - @Published var carrier: String = "none" - @Published var srcState: String = "never" - @Published var grid: String = "—" - @Published var pv: String = "—" - @Published var battery: String = "—" - @Published var load: String = "—" - @Published var help: String? - @Published var paste: String = "" - - private let vault: Vault - private let sites: SiteStore - private let readings: ReadingsCache - private lazy var client: FtwClient = FtwClient( - vault: vault, - sites: sites, - sockets: IosSockets(), - passkeys: IosPasskey(), - relayUrl: OriginKt.RELAY_URL, - build: "ios" - ) - private var session: Session? - private var epoch = 0 - - init() { - let kv = KeychainStore() - vault = Vault(store: kv) - sites = SiteStore(kv: kv) - readings = ReadingsCache(kv: kv) - if let paired = sites.all().first { - site = paired - if let cached = readings.get() { - let nums = NowNumbersKt.nowNumbers(fields: cached.fields) - headline = SessionKt.headlineOf(readings: cached) - carrier = "cache" - grid = nums.grid - pv = nums.pv - battery = nums.battery - load = nums.load - } - connect() - } - } - - func pair() { - help = nil - let scanned = paste - Task { - do { - let wrapping = try await IosPasskey.enroll() - let paired = try client.pair(scanned: scanned, wrapping: wrapping) - await MainActor.run { - self.site = paired - self.connect() - } - } catch let err as EnrollmentError { - await MainActor.run { self.help = err.help } - } catch let err as VaultError { - await MainActor.run { self.help = err.help } - } catch { - await MainActor.run { - self.help = "That code did not read cleanly. Hold the phone steady and scan it again." - } - } - } - } - - func applyScanned(_ raw: String) { - paste = raw - pair() - } - - func connect() { - guard let site else { return } - session?.close() - epoch += 1 - let mine = epoch - do { - let session = try client.connect(site: site) - self.session = session - if let cached = readings.get() { - session.restore(readings: cached) - } - session.subscribe { [weak self] snap in - let hasFields = snap.readings.fields.count > 0 - let nums = NowNumbersKt.nowNumbers(fields: snap.readings.fields) - DispatchQueue.main.async { - guard let self, self.epoch == mine else { return } - self.headline = snap.headline - self.carrier = String(describing: snap.carrier) - self.srcState = String(describing: snap.srcState) - if hasFields { - self.grid = nums.grid - self.pv = nums.pv - self.battery = nums.battery - self.load = nums.load - self.readings.put(readings: snap.readings) - } - } - } - } catch let err as ConnectError { - help = err.help - } catch { - help = "Could not reach your box." - } - } - - func wake() { - session?.wake() - } - - func forget() { - epoch += 1 - session?.close() - session = nil - vault.clear() - sites.clear() - readings.clear() - site = nil - headline = "Waiting for the first reading." - carrier = "none" - srcState = "never" - grid = "—" - pv = "—" - battery = "—" - load = "—" - help = nil - paste = "" - } -} diff --git a/iosApp/iosApp/FTWApp.swift b/iosApp/iosApp/FTWApp.swift deleted file mode 100644 index ed7d635..0000000 --- a/iosApp/iosApp/FTWApp.swift +++ /dev/null @@ -1,43 +0,0 @@ -import SwiftUI -import Shared - -@main -struct FTWApp: App { - @StateObject private var model = AppModel() - - var body: some Scene { - WindowGroup { - RootView() - .environmentObject(model) - } - } -} - -struct RootView: View { - @EnvironmentObject var model: AppModel - @Environment(\.scenePhase) private var scenePhase - - var body: some View { - Group { - if model.site != nil { - NowView() - } else { - PairView() - } - } - .safeAreaInset(edge: .bottom) { - DisclosureGroup("Source & licenses") { - Text("© 2026 Sourceful Labs AB and contributors. You may copy, modify and share under the license. No warranty to the extent permitted by law.") - HStack { - Link("Source", destination: URL(string: SourceLicense.shared.sourceUrl)!) - Link("AGPLv3 + Energyplan permission", destination: URL(string: SourceLicense.shared.licenseUrl)!) - } - } - .font(.caption) - .padding(8) - } - .onChange(of: scenePhase) { _, phase in - if phase == .active { model.wake() } - } - } -} diff --git a/iosApp/iosApp/Info.plist b/iosApp/iosApp/Info.plist deleted file mode 100644 index 539b00a..0000000 --- a/iosApp/iosApp/Info.plist +++ /dev/null @@ -1,36 +0,0 @@ - - - - - CFBundleDevelopmentRegion - en - CFBundleDisplayName - FTW - CFBundleExecutable - $(EXECUTABLE_NAME) - CFBundleIdentifier - $(PRODUCT_BUNDLE_IDENTIFIER) - CFBundleInfoDictionaryVersion - 6.0 - CFBundleName - FTW - CFBundlePackageType - APPL - CFBundleShortVersionString - 0.1.0 - CFBundleVersion - 1 - LSRequiresIPhoneOS - - NSCameraUsageDescription - FTW uses the camera to scan the pairing code on your box. - NSFaceIDUsageDescription - FTW uses Face ID to unlock the key that talks to your box. - UILaunchScreen - - UISupportedInterfaceOrientations - - UIInterfaceOrientationPortrait - - - diff --git a/iosApp/iosApp/KeychainStore.swift b/iosApp/iosApp/KeychainStore.swift deleted file mode 100644 index bff0d3e..0000000 --- a/iosApp/iosApp/KeychainStore.swift +++ /dev/null @@ -1,59 +0,0 @@ -import Foundation -import Security -import Shared - -/// Generic-password KeyValueStore. After first unlock, this device only — readable at cold start without Face ID. -final class KeychainStore: NSObject, KeyValueStore { - private let service = "energy.ftw.app" - - func get(key: String) -> KotlinByteArray? { - let query: [String: Any] = [ - kSecClass as String: kSecClassGenericPassword, - kSecAttrService as String: service, - kSecAttrAccount as String: key, - kSecReturnData as String: true, - kSecMatchLimit as String: kSecMatchLimitOne, - ] - var result: AnyObject? - let status = SecItemCopyMatching(query as CFDictionary, &result) - guard status == errSecSuccess, let data = result as? Data else { return nil } - return data.kotlinByteArray - } - - func put(key: String, value: KotlinByteArray) { - let data = value.data - let query: [String: Any] = [ - kSecClass as String: kSecClassGenericPassword, - kSecAttrService as String: service, - kSecAttrAccount as String: key, - ] - SecItemDelete(query as CFDictionary) - var item = query - item[kSecValueData as String] = data - item[kSecAttrAccessible as String] = kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly - SecItemAdd(item as CFDictionary, nil) - } - - func remove(key: String) { - let query: [String: Any] = [ - kSecClass as String: kSecClassGenericPassword, - kSecAttrService as String: service, - kSecAttrAccount as String: key, - ] - SecItemDelete(query as CFDictionary) - } -} - -private extension Data { - var kotlinByteArray: KotlinByteArray { - let arr = KotlinByteArray(size: Int32(count)) - enumerated().forEach { i, b in arr.set(index: Int32(i), value: Int8(bitPattern: b)) } - return arr - } -} - -private extension KotlinByteArray { - var data: Data { - Data((0.. some View { - HStack { - Text(name).foregroundStyle(Color(red: 0.63, green: 0.63, blue: 0.63)) - Spacer() - Text(value).foregroundStyle(Color(red: 0.91, green: 0.91, blue: 0.91)) - } - } -} diff --git a/iosApp/iosApp/PairView.swift b/iosApp/iosApp/PairView.swift deleted file mode 100644 index 20f41b3..0000000 --- a/iosApp/iosApp/PairView.swift +++ /dev/null @@ -1,66 +0,0 @@ -import SwiftUI - -struct PairView: View { - @EnvironmentObject var model: AppModel - @State private var scanning = false - - var body: some View { - VStack(spacing: 24) { - Spacer() - Text("FTW") - .font(.largeTitle.weight(.semibold)) - .foregroundStyle(Color(red: 0.91, green: 0.91, blue: 0.91)) - Text("Scan the pairing code on your box.") - .font(.body) - .foregroundStyle(Color(red: 0.63, green: 0.63, blue: 0.63)) - .multilineTextAlignment(.center) - if scanning { - QRScanner { code in - scanning = false - model.applyScanned(code) - } - .frame(height: 280) - .clipShape(RoundedRectangle(cornerRadius: 16)) - } else { - Button { - scanning = true - } label: { - ZStack { - RoundedRectangle(cornerRadius: 16) - .strokeBorder(Color(red: 0.16, green: 0.16, blue: 0.16), lineWidth: 1) - .frame(height: 240) - VStack(spacing: 8) { - Image(systemName: "qrcode.viewfinder") - .font(.system(size: 48)) - .foregroundStyle(Color(red: 0.85, green: 0.82, blue: 0.25)) - Text("Scan") - .font(.footnote) - .foregroundStyle(Color(red: 0.52, green: 0.52, blue: 0.52)) - } - } - } - } - TextField("Or paste a pairing link", text: $model.paste) - .textInputAutocapitalization(.never) - .autocorrectionDisabled() - .padding(12) - .background(Color(red: 0.09, green: 0.09, blue: 0.09)) - .clipShape(RoundedRectangle(cornerRadius: 8)) - .foregroundStyle(Color(red: 0.91, green: 0.91, blue: 0.91)) - Button("Continue", action: model.pair) - .buttonStyle(.borderedProminent) - .tint(Color(red: 0.85, green: 0.82, blue: 0.25)) - .foregroundStyle(Color(red: 0.04, green: 0.04, blue: 0.04)) - if let help = model.help { - Text(help) - .font(.footnote) - .foregroundStyle(Color(red: 0.72, green: 0.18, blue: 0.12)) - .multilineTextAlignment(.center) - } - Spacer() - } - .padding(24) - .frame(maxWidth: .infinity, maxHeight: .infinity) - .background(Color(red: 0.05, green: 0.05, blue: 0.05)) - } -} diff --git a/iosApp/iosApp/PasskeyHost.swift b/iosApp/iosApp/PasskeyHost.swift deleted file mode 100644 index e8dc5b9..0000000 --- a/iosApp/iosApp/PasskeyHost.swift +++ /dev/null @@ -1,150 +0,0 @@ -import AuthenticationServices -import CryptoKit -import Foundation -import Shared -import UIKit - -/// PasskeyHost for FTW. RP ID app.ftw.energy, PRF salt ftw.prf.v1.vault. -final class IosPasskey: NSObject, PasskeyHost { - var rpId: String { "app.ftw.energy" } - var rpName: String { "FTW" } - var prfSalt: KotlinByteArray { Data("ftw.prf.v1.vault".utf8).kotlinByteArray } - - func enroll(label: String) -> WrappingKey { - bridge { try await IosPasskey.enroll(label: label) } - } - - func unlock(label: String) -> WrappingKey { - bridge { try await IosPasskey.unlock(label: label) } - } - - @MainActor - static func enroll(label: String = "FTW") async throws -> WrappingKey { - try await ceremony(register: true, label: label) - } - - @MainActor - static func unlock(label: String = "FTW") async throws -> WrappingKey { - try await ceremony(register: false, label: label) - } - - @MainActor - private static func ceremony(register: Bool, label: String) async throws -> WrappingKey { - let provider = ASAuthorizationPlatformPublicKeyCredentialProvider(relyingPartyIdentifier: "app.ftw.energy") - let challenge = Data((0..<32).map { _ in UInt8.random(in: 0...255) }) - let salt = Data("ftw.prf.v1.vault".utf8) - let request: ASAuthorizationRequest - if register { - let userId = Data((0..<16).map { _ in UInt8.random(in: 0...255) }) - let create = provider.createCredentialRegistrationRequest( - challenge: challenge, - name: label, - userID: userId - ) - create.userVerificationPreference = .required - if #available(iOS 18.0, *) { - create.prf = .inputValues(.init(saltInput1: salt)) - } - request = create - } else { - let get = provider.createCredentialAssertionRequest(challenge: challenge) - get.userVerificationPreference = .required - if #available(iOS 18.0, *) { - get.prf = .inputValues(.init(saltInput1: salt)) - } - request = get - } - let controller = ASAuthorizationController(authorizationRequests: [request]) - let delegate = Delegate() - controller.delegate = delegate - controller.presentationContextProvider = delegate - return try await withCheckedThrowingContinuation { cont in - delegate.cont = cont - controller.performRequests() - } - } - - private func bridge(_ work: @escaping () async throws -> WrappingKey) -> WrappingKey { - let slot = Slot() - Task { @MainActor in - do { - slot.value = try await work() - } catch { - slot.error = error - } - slot.done = true - } - let deadline = Date().addingTimeInterval(120) - while Date() < deadline && !slot.done { - RunLoop.current.run(mode: .default, before: Date(timeIntervalSinceNow: 0.05)) - } - if let value = slot.value { return value } - let reason = slot.error?.localizedDescription ?? "timed out" - preconditionFailure("passkey ceremony failed: \(reason)") - } -} - -private final class Slot { - var value: WrappingKey? - var error: Error? - var done = false -} - -private final class Delegate: NSObject, ASAuthorizationControllerDelegate, ASAuthorizationControllerPresentationContextProviding { - var cont: CheckedContinuation? - - func presentationAnchor(for controller: ASAuthorizationController) -> ASPresentationAnchor { - UIApplication.shared.connectedScenes - .compactMap { $0 as? UIWindowScene } - .flatMap { $0.windows } - .first { $0.isKeyWindow } ?? ASPresentationAnchor() - } - - func authorizationController(controller: ASAuthorizationController, didCompleteWithAuthorization authorization: ASAuthorization) { - var prf: Data? - var credId = Data() - if #available(iOS 18.0, *) { - if let cred = authorization.credential as? ASAuthorizationPlatformPublicKeyCredentialRegistration { - credId = cred.credentialID - prf = cred.prf?.first?.rawData - } - if let cred = authorization.credential as? ASAuthorizationPlatformPublicKeyCredentialAssertion { - credId = cred.credentialID - if let out = cred.prf { prf = out.first.rawData } - } - } else if let cred = authorization.credential as? ASAuthorizationPlatformPublicKeyCredentialRegistration { - credId = cred.credentialID - } else if let cred = authorization.credential as? ASAuthorizationPlatformPublicKeyCredentialAssertion { - credId = cred.credentialID - } - let key = prf ?? Data((0..<32).map { _ in UInt8.random(in: 0...255) }) - let source: WrappingSource = prf == nil ? .local : .prf - cont?.resume( - returning: WrappingKey( - credentialId: credId.base64EncodedString(), - source: source, - key: key.kotlinByteArray - ) - ) - cont = nil - } - - func authorizationController(controller: ASAuthorizationController, didCompleteWithError error: Error) { - cont?.resume(throwing: error) - cont = nil - } -} - -private extension SymmetricKey { - var rawData: Data { - withUnsafeBytes { Data($0) } - } -} - -private extension Data { - var kotlinByteArray: KotlinByteArray { - let arr = KotlinByteArray(size: Int32(count)) - enumerated().forEach { i, b in arr.set(index: Int32(i), value: Int8(bitPattern: b)) } - return arr - } -} diff --git a/iosApp/iosApp/QRScanner.swift b/iosApp/iosApp/QRScanner.swift deleted file mode 100644 index 5dc5940..0000000 --- a/iosApp/iosApp/QRScanner.swift +++ /dev/null @@ -1,55 +0,0 @@ -import AVFoundation -import SwiftUI -import Vision - -/// Native camera QR scanner. Hands the first FTW pairing URL to the shared parser. -struct QRScanner: UIViewControllerRepresentable { - var onCode: (String) -> Void - - func makeUIViewController(context: Context) -> ScannerController { - let c = ScannerController() - c.onCode = onCode - return c - } - - func updateUIViewController(_ uiViewController: ScannerController, context: Context) {} -} - -final class ScannerController: UIViewController, AVCaptureVideoDataOutputSampleBufferDelegate { - var onCode: ((String) -> Void)? - private let session = AVCaptureSession() - private var locked = false - - override func viewDidLoad() { - super.viewDidLoad() - view.backgroundColor = .black - guard let device = AVCaptureDevice.default(.builtInWideAngleCamera, for: .video, position: .back), - let input = try? AVCaptureDeviceInput(device: device) else { return } - session.addInput(input) - let output = AVCaptureVideoDataOutput() - output.setSampleBufferDelegate(self, queue: DispatchQueue(label: "ftw.qr")) - session.addOutput(output) - let preview = AVCaptureVideoPreviewLayer(session: session) - preview.videoGravity = .resizeAspectFill - preview.frame = view.bounds - view.layer.addSublayer(preview) - DispatchQueue.global(qos: .userInitiated).async { self.session.startRunning() } - } - - func captureOutput(_ output: AVCaptureOutput, didOutput sampleBuffer: CMSampleBuffer, from connection: AVCaptureConnection) { - if locked { return } - guard let pixel = CMSampleBufferGetImageBuffer(sampleBuffer) else { return } - let request = VNDetectBarcodesRequest { [weak self] req, _ in - guard let code = (req.results as? [VNBarcodeObservation])?.first?.payloadStringValue else { return } - guard code.contains("ftw.energy") || code.contains("/p#") else { return } - DispatchQueue.main.async { - guard let self, !self.locked else { return } - self.locked = true - self.session.stopRunning() - self.onCode?(code) - } - } - request.symbologies = [.qr] - try? VNImageRequestHandler(cvPixelBuffer: pixel, options: [:]).perform([request]) - } -} diff --git a/shared/build.gradle.kts b/shared/build.gradle.kts index fc1dacb..f007153 100644 --- a/shared/build.gradle.kts +++ b/shared/build.gradle.kts @@ -47,17 +47,8 @@ kotlin { } } jvm() - - listOf( - iosX64(), - iosArm64(), - iosSimulatorArm64(), - ).forEach { target -> - target.binaries.framework { - baseName = "Shared" - isStatic = true - } - } + // iOS and macOS are pure Swift now: appleApp/ carries its own copy of + // this logic in FTWKit, checked against the same vectors. sourceSets { commonMain { diff --git a/shared/src/iosMain/kotlin/energy/ftw/carrier/IosSockets.kt b/shared/src/iosMain/kotlin/energy/ftw/carrier/IosSockets.kt deleted file mode 100644 index fdceb20..0000000 --- a/shared/src/iosMain/kotlin/energy/ftw/carrier/IosSockets.kt +++ /dev/null @@ -1,75 +0,0 @@ -package energy.ftw.carrier - -import kotlinx.cinterop.ExperimentalForeignApi -import kotlinx.cinterop.addressOf -import kotlinx.cinterop.convert -import kotlinx.cinterop.usePinned -import platform.Foundation.NSData -import platform.Foundation.NSMutableURLRequest -import platform.Foundation.NSURL -import platform.Foundation.NSURLSession -import platform.Foundation.NSURLSessionWebSocketMessage -import platform.Foundation.NSURLSessionWebSocketTask -import platform.Foundation.dataWithBytes -import platform.darwin.dispatch_async -import platform.darwin.dispatch_get_main_queue -import platform.posix.memcpy - -@OptIn(ExperimentalForeignApi::class) -class IosSockets : SocketFactory { - override fun open(url: String, listener: SocketListener): RawSocket { - val nsUrl = NSURL.URLWithString(url) ?: error("bad url") - val request = NSMutableURLRequest.requestWithURL(nsUrl) - val task = NSURLSession.sharedSession.webSocketTaskWithRequest(request) - val handle = object : RawSocket { - override fun sendBinary(bytes: ByteArray) { - bytes.usePinned { pinned -> - val data = NSData.dataWithBytes(pinned.addressOf(0), bytes.size.convert()) - task.sendMessage(NSURLSessionWebSocketMessage(data)) { _ -> } - } - } - - override fun close() { - task.cancel() - } - } - receive(task, listener) - task.resume() - dispatch_async(dispatch_get_main_queue()) { listener.onOpen() } - return handle - } - - private fun receive(task: NSURLSessionWebSocketTask, listener: SocketListener) { - task.receiveMessageWithCompletionHandler { message, error -> - if (error != null) { - val code = task.closeCode.toInt() - val reasonBytes = task.closeReason - val reason = if (reasonBytes != null && reasonBytes.length.toInt() > 0) { - nsDataToBytes(reasonBytes).decodeToString() - } else { - error.localizedDescription - } - listener.onClose(if (code == 0) 1006 else code, reason) - return@receiveMessageWithCompletionHandler - } - val text = message?.string - val data = message?.data - if (text != null) listener.onText(text) - if (data != null) { - listener.onBinary(nsDataToBytes(data)) - } - receive(task, listener) - } - } -} - -@OptIn(ExperimentalForeignApi::class) -private fun nsDataToBytes(data: NSData): ByteArray { - val n = data.length.toInt() - if (n == 0) return ByteArray(0) - val out = ByteArray(n) - out.usePinned { pinned -> - memcpy(pinned.addressOf(0), data.bytes, n.convert()) - } - return out -} diff --git a/shared/src/iosMain/kotlin/energy/ftw/carrier/Retry.ios.kt b/shared/src/iosMain/kotlin/energy/ftw/carrier/Retry.ios.kt deleted file mode 100644 index 860ad9c..0000000 --- a/shared/src/iosMain/kotlin/energy/ftw/carrier/Retry.ios.kt +++ /dev/null @@ -1,11 +0,0 @@ -package energy.ftw.carrier - -import platform.darwin.DISPATCH_TIME_NOW -import platform.darwin.dispatch_after -import platform.darwin.dispatch_get_main_queue -import platform.darwin.dispatch_time - -internal actual fun scheduleRetry(delayMs: Long, block: () -> Unit) { - val ns = delayMs * 1_000_000L - dispatch_after(dispatch_time(DISPATCH_TIME_NOW, ns), dispatch_get_main_queue(), block) -} diff --git a/shared/src/iosMain/kotlin/energy/ftw/carrier/Time.ios.kt b/shared/src/iosMain/kotlin/energy/ftw/carrier/Time.ios.kt deleted file mode 100644 index c3715dc..0000000 --- a/shared/src/iosMain/kotlin/energy/ftw/carrier/Time.ios.kt +++ /dev/null @@ -1,6 +0,0 @@ -package energy.ftw.carrier - -import platform.Foundation.NSDate -import platform.Foundation.timeIntervalSince1970 - -actual fun nowMs(): Long = (NSDate().timeIntervalSince1970 * 1000.0).toLong() diff --git a/shared/src/iosMain/kotlin/energy/ftw/crypto/Random.ios.kt b/shared/src/iosMain/kotlin/energy/ftw/crypto/Random.ios.kt deleted file mode 100644 index f3ce84c..0000000 --- a/shared/src/iosMain/kotlin/energy/ftw/crypto/Random.ios.kt +++ /dev/null @@ -1,16 +0,0 @@ -package energy.ftw.crypto - -import kotlinx.cinterop.ExperimentalForeignApi -import kotlinx.cinterop.addressOf -import kotlinx.cinterop.usePinned -import platform.Security.SecRandomCopyBytes -import platform.Security.errSecSuccess -import platform.Security.kSecRandomDefault - -@OptIn(ExperimentalForeignApi::class) -actual fun fillRandom(bytes: ByteArray) { - val rc = bytes.usePinned { pinned -> - SecRandomCopyBytes(kSecRandomDefault, bytes.size.toULong(), pinned.addressOf(0)) - } - require(rc == errSecSuccess) { "SecRandomCopyBytes failed: $rc" } -}