diff --git a/.github/workflows/apple.yml b/.github/workflows/apple.yml new file mode 100644 index 0000000..a5ec938 --- /dev/null +++ b/.github/workflows/apple.yml @@ -0,0 +1,50 @@ +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: + apple: + name: FTWKit and the iPhone app + runs-on: macos-26 + 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, FTWShared and FTWDemo + working-directory: appleApp/FTWKit + run: swift test + # Signed ad hoc, as FTW.xcconfig sets for the simulator, so the App + # Group entitlement travels with the app and the widget. Device + # builds need a team, which the project does not have yet. + - name: Build the app and its widgets for the iOS Simulator + working-directory: appleApp + run: >- + xcodebuild build -quiet -project FTW.xcodeproj -scheme FTW + -destination 'generic/platform=iOS Simulator' + -derivedDataPath build/ios + # For whoever reviews the screens: the demo, photographed in the + # simulator. Evidence, not a gate, so a flaky simulator does not turn + # the build red. + - name: Photograph the demo in the iOS Simulator + continue-on-error: true + working-directory: appleApp + 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/.github/workflows/ios-release.yml b/.github/workflows/ios-release.yml new file mode 100644 index 0000000..adc3752 --- /dev/null +++ b/.github/workflows/ios-release.yml @@ -0,0 +1,207 @@ +name: iOS release + +# Archives the iPhone app and sends it to App Store Connect, where it lands +# in TestFlight. A tag `ios-v1.2.3` ships version 1.2.3, and so do +# `ios-v1.2.3-1`, `ios-v1.2.3-2`: anything after a dash only tells tags +# apart. A manual run ships the version in appleApp/FTW.xcconfig. The build +# number is this workflow's run number. appleApp/RELEASING.md has the steps +# around it. +# +# Without the three ASC_* secrets it builds an unsigned archive instead and +# says that the upload was skipped. A pull request that changes this file or +# the export options always takes that path, secrets or not. + +on: + workflow_dispatch: + push: + tags: ['ios-v*'] + pull_request: + paths: ['.github/workflows/ios-release.yml', 'appleApp/ExportOptions.plist'] + +permissions: + contents: read + +concurrency: + group: ios-release-${{ github.ref }} + cancel-in-progress: false + +jobs: + release: + name: Archive the iPhone app for App Store Connect + # GitHub's image with Xcode 27 (public preview in October 2026). The + # macos-26 image stops at Xcode 26.6. + runs-on: xcode-27 + timeout-minutes: 60 + # The key's secrets may live in the repository or, safer, in this + # environment, which only main and ios-v* tags may use (RELEASING.md). + # A pull request needs no key. + environment: ${{ github.event_name != 'pull_request' && 'app-store' || '' }} + env: + # The release, by path. Picking the newest Xcode on this image would + # pick a beta. + DEVELOPER_DIR: /Applications/Xcode_27.0.app/Contents/Developer + steps: + - uses: actions/checkout@v4 + + - name: Show Xcode and the iOS SDK + run: | + xcodebuild -version + xcodebuild -version -sdk iphoneos SDKVersion + + - name: Test FTWKit, FTWShared and FTWDemo + working-directory: appleApp/FTWKit + run: swift test + + - name: Choose the version and build number + id: version + run: | + if [[ $GITHUB_REF_TYPE == tag && $GITHUB_REF_NAME == ios-v* ]]; then + version=${GITHUB_REF_NAME#ios-v} + version=${version%%-*} + else + version=$(sed -n 's/^MARKETING_VERSION *= *//p' appleApp/FTW.xcconfig) + fi + # What App Store Connect accepts: one to three whole numbers. + if ! [[ $version =~ ^[0-9]+(\.[0-9]+){0,2}$ ]]; then + echo "::error::'$version' is not a version App Store Connect accepts. Tag as ios-v1.2.3." + exit 1 + fi + # A re-run keeps the run number, and App Store Connect refuses a + # build number twice. + build=$GITHUB_RUN_NUMBER + if (( GITHUB_RUN_ATTEMPT > 1 )); then build=$build.$GITHUB_RUN_ATTEMPT; fi + echo "version=$version" >> "$GITHUB_OUTPUT" + echo "build=$build" >> "$GITHUB_OUTPUT" + echo "Version $version, build $build" + + # Secrets cannot appear in an `if`, so this step turns them into a + # yes or no. It prints nothing about them. ASC_KEY_P8 is the .p8 file's + # PEM text as Apple gives it; the other two are plain text. + - name: Check for the App Store Connect key + id: key + env: + ASC_KEY_ID: ${{ secrets.ASC_KEY_ID }} + ASC_ISSUER_ID: ${{ secrets.ASC_ISSUER_ID }} + ASC_KEY_P8: ${{ secrets.ASC_KEY_P8 }} + run: | + if [[ $GITHUB_EVENT_NAME != pull_request && -n $ASC_KEY_ID && -n $ASC_ISSUER_ID && -n $ASC_KEY_P8 ]]; then + key=$RUNNER_TEMP/AuthKey.p8 + (umask 077; printf '%s\n' "$ASC_KEY_P8" > "$key") + echo "path=$key" >> "$GITHUB_OUTPUT" + echo "upload=true" >> "$GITHUB_OUTPUT" + else + echo "upload=false" >> "$GITHUB_OUTPUT" + fi + + # Xcode signs with Sourceful's team (FTW.xcconfig) and makes what it + # needs through the key: a development certificate on this runner for + # the archive, then the App Store profiles and a cloud-managed + # distribution certificate for the export. + - name: Archive, signed + if: steps.key.outputs.upload == 'true' + working-directory: appleApp + env: + ASC_KEY_ID: ${{ secrets.ASC_KEY_ID }} + ASC_ISSUER_ID: ${{ secrets.ASC_ISSUER_ID }} + run: >- + xcodebuild archive -quiet -project FTW.xcodeproj -scheme FTW + -configuration Release -destination 'generic/platform=iOS' + -archivePath "$RUNNER_TEMP/FTW.xcarchive" + -allowProvisioningUpdates + -authenticationKeyPath "${{ steps.key.outputs.path }}" + -authenticationKeyID "$ASC_KEY_ID" + -authenticationKeyIssuerID "$ASC_ISSUER_ID" + MARKETING_VERSION=${{ steps.version.outputs.version }} + CURRENT_PROJECT_VERSION=${{ steps.version.outputs.build }} + FTW_SOURCE_REVISION=$GITHUB_SHA + + - name: Archive, unsigned (dry run) + if: steps.key.outputs.upload != 'true' + working-directory: appleApp + run: >- + xcodebuild archive -quiet -project FTW.xcodeproj -scheme FTW + -configuration Release -destination 'generic/platform=iOS' + -archivePath "$RUNNER_TEMP/FTW.xcarchive" + CODE_SIGNING_ALLOWED=NO + MARKETING_VERSION=${{ steps.version.outputs.version }} + CURRENT_PROJECT_VERSION=${{ steps.version.outputs.build }} + FTW_SOURCE_REVISION=$GITHUB_SHA + + # App Store Connect refuses a widget whose version differs from its + # app's, so check both before sending anything. + - name: Check the version in the app and the widget + run: | + app=$RUNNER_TEMP/FTW.xcarchive/Products/Applications/FTW.app + for plist in "$app/Info.plist" "$app"/PlugIns/*.appex/Info.plist; do + got=$(plutil -extract CFBundleShortVersionString raw "$plist")/$(plutil -extract CFBundleVersion raw "$plist") + echo "$got ${plist#$app/} $(plutil -extract DTSDKName raw "$plist")" + [[ $got == ${{ steps.version.outputs.version }}/${{ steps.version.outputs.build }} ]] || { echo "::error::$plist has $got"; exit 1; } + done + + # Xcode signs and uploads in one step. If that fails, the next step + # tries the other way Apple documents. + - name: Upload to App Store Connect + id: upload + if: steps.key.outputs.upload == 'true' + continue-on-error: true + working-directory: appleApp + env: + ASC_KEY_ID: ${{ secrets.ASC_KEY_ID }} + ASC_ISSUER_ID: ${{ secrets.ASC_ISSUER_ID }} + run: >- + xcodebuild -exportArchive + -archivePath "$RUNNER_TEMP/FTW.xcarchive" + -exportOptionsPlist ExportOptions.plist + -exportPath "$RUNNER_TEMP/export" + -allowProvisioningUpdates + -authenticationKeyPath "${{ steps.key.outputs.path }}" + -authenticationKeyID "$ASC_KEY_ID" + -authenticationKeyIssuerID "$ASC_ISSUER_ID" + + # Apple's "Upload builds" help still lists altool beside Xcode and + # Transporter. Export the signed .ipa, then send it with the same key. + - name: Upload with altool instead + id: altool + if: steps.upload.outcome == 'failure' + working-directory: appleApp + env: + ASC_KEY_ID: ${{ secrets.ASC_KEY_ID }} + ASC_ISSUER_ID: ${{ secrets.ASC_ISSUER_ID }} + run: | + echo "::warning title=Xcode upload failed::Exporting an .ipa and uploading it with altool instead." + plutil -replace destination -string export -o "$RUNNER_TEMP/ExportOptions-ipa.plist" ExportOptions.plist + xcodebuild -exportArchive \ + -archivePath "$RUNNER_TEMP/FTW.xcarchive" \ + -exportOptionsPlist "$RUNNER_TEMP/ExportOptions-ipa.plist" \ + -exportPath "$RUNNER_TEMP/ipa" \ + -allowProvisioningUpdates \ + -authenticationKeyPath "${{ steps.key.outputs.path }}" \ + -authenticationKeyID "$ASC_KEY_ID" \ + -authenticationKeyIssuerID "$ASC_ISSUER_ID" + xcrun altool --upload-package "$RUNNER_TEMP"/ipa/*.ipa \ + --api-key "$ASC_KEY_ID" --api-issuer "$ASC_ISSUER_ID" \ + --p8-file-path "${{ steps.key.outputs.path }}" + + - name: Report + run: | + v="${{ steps.version.outputs.version }} (${{ steps.version.outputs.build }})" + if [[ "${{ steps.key.outputs.upload }}" == true ]]; then + how=Xcode; [[ "${{ steps.altool.outcome }}" == success ]] && how=altool + echo "FTW $v went to App Store Connect through $how. It shows in TestFlight once Apple has processed it." >> "$GITHUB_STEP_SUMMARY" + else + echo "::warning title=Upload skipped::Dry run: FTW $v was archived unsigned and not sent to App Store Connect. A pull request never uploads; otherwise set ASC_KEY_ID, ASC_ISSUER_ID and ASC_KEY_P8 (appleApp/RELEASING.md)." + echo "**Upload skipped.** Dry run: FTW $v was archived unsigned and not sent to App Store Connect." >> "$GITHUB_STEP_SUMMARY" + fi + + - name: Remove the key + if: always() + run: rm -f "$RUNNER_TEMP/AuthKey.p8" + + # Xcode's own account of the export, for when App Store Connect says no. + - name: Keep the export logs + if: always() && steps.key.outputs.upload == 'true' && (failure() || steps.upload.outcome == 'failure') + uses: actions/upload-artifact@v4 + with: + name: export-logs + path: ${{ runner.temp }}/export + if-no-files-found: ignore diff --git a/.gitignore b/.gitignore index 6bfa9df..00894b1 100644 --- a/.gitignore +++ b/.gitignore @@ -18,10 +18,16 @@ DerivedData/ *.aab captures/ -# iOS -iosApp/Pods/ +# Xcode *.xcworkspace/xcuserdata/ # 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..5942a40 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1,6 +1,8 @@ # FTW native app — project guide -Kotlin Multiplatform shared logic. SwiftUI on iOS. Jetpack Compose on Android. +Two native apps. iPhone: pure Swift in `appleApp/` (FTWKit, FTWShared, +FTWDemo, SwiftUI, a WidgetKit extension). Android: Jetpack Compose on Kotlin +Multiplatform shared logic in `shared/`. Talks to an FTW box over an encrypted session; the box is the authority and this app is a cached projection of it. @@ -17,15 +19,33 @@ and useful notifications. Those goals do not remove the current native release gates below or claim features are present on either phone. Reuse Core's contracts and authority as native scope expands. -## Current v1 +## Current scope -Pair + Now only. Shipped on `main` as of 2026-08-22. Persist vault, site and -last readings on the phone. Cold start paints the cache, then reconnects -without a passkey. README Status lists what was proven and what is still open. +**iPhone (`appleApp/`).** On 2026-10-12 Fredrik approved M1 for an +iPhone-only app on iOS 26: pairing, a lean Now, a car view and price +widgets. The car view sets the level with 20/40/60/80 and ±5 through +`loadpoint.soc.set`, the goal through the schedule PUT, and has Charge now, +Pause and Back to the plan. M1 needs no server and no change in Core. +His decisions: -Do not add Energy / History / Plan / EV, commands, escrow, LAN, push, or store -listing until Pair + Now is solid on both phones, including wrap-key parity -with the web app. +- iOS 26 is the minimum. iPhone only, no macOS. +- Notification text is written on the phone, in Swedish and English. +- Commands from the lock screen, Siri and notifications require unlock. +- Charge now runs with no time limit. + +Do not add Plan, History, Box admin, escrow, LAN, push or the store listing +in M1. FTW Cloud (an opt-in state cache, push and statistics, readable by +Sourceful rather than end-to-end encrypted) is designed separately; nothing +in M1 may depend on it. + +**Android (`androidApp/`, `shared/`).** Pair + Now, shipped on `main` as of +2026-08-22. Do not add Energy / History / Plan / EV, commands, escrow, LAN, +push, or store listing on Android until Pair + Now is solid there, including +wrap-key parity with the web app. + +Both: persist vault, site and last readings on the phone. Cold start paints +the cache, then reconnects without a passkey. README Status lists what was +proven and what is still open. The protocol, the QR, the relay and the identity model are specified in [ftw-webapp](https://github.com/srcfl/ftw-webapp) `docs/architecture.md` and @@ -39,10 +59,48 @@ The protocol, the QR, the relay and the identity model are specified in - Few required choices. Hide transport setup; expose useful household goals and expert controls when their flows are implemented. - Failures heal themselves. A dropped connection reconnects on its own. - There is no reconnect button. + There is no reconnect button. A relay that is full (4429) says FTW is + busy, not that something broke. - Errors say what happens now, not what broke inside. - Least code that does the whole job. +## Feel and speed + +Fredrik's bar for the iPhone app (2026-10-12): modern, very fast, truly +native, and smooth enough to impress. + +- **First frame from the cache.** No network wait and no spinner before + it. While nothing is known, show the shape of the screen + (`.redacted(reason: .placeholder)`), not a spinner. `LaunchClock` logs + the time from process start to the first frame and the first content; + quote the numbers in any PR that touches launch. +- **The handshake runs beside the first frame, never ahead of it.** +- **No timer loops.** Lane 0 drives Now. Until the box streams charger + state (M5, or FTW Cloud C3), the chargers are read when a screen that + shows them appears, after a command, and when the stream says the car + started or stopped drawing. The plan's shortfall is read when the car + view opens and when the car's plan changes. +- **A frame redraws what moved.** Small views that read only what they + show, value inputs, and `Equatable` with `.equatable()` where a frame + would otherwise redraw a whole section. A clock on screen ticks by the + minute unless the second matters. +- **Modern Swift.** Swift 6 strict concurrency, `@Observable`, + async/await and structured concurrency. No Combine, no GCD, no + `ObservableObject`, and no UIKit except the camera. Colours live in + asset catalogs, not `UIColor`. +- **iOS 26's design.** System components (`List`, `Form`, + `NavigationStack`, system button styles), so Liquid Glass appears where + the system puts it. SF Symbols with symbol effects. +- **Feel.** Numbers change with `.contentTransition(.numericText())`. + Motion uses one spring, `Theme.spring`. The car row on Now zooms into + the car view (`navigationTransition(.zoom)`). `.sensoryFeedback` marks + commands and their outcomes. +- **A command answers at once.** Tap, haptic and "Sent" in under 100 ms, + then the box's receipt (`cmd.ack`), then the outcome (`cmd.result`). + Show the intent at once; never show a result the box has not confirmed. +- **The largest text size and dark mode** look as good as the default. + Review all three in the simulator. + ## Non-negotiable invariants - **Never fake live.** Every reading carries its age. @@ -56,27 +114,47 @@ The protocol, the QR, the relay and the identity model are specified in - **The cache is a cache, never the original.** - **The cache key is not PRF-wrapped.** Cold start paints before Face ID. PRF gates enrollment and privileged commands, not reading. +- **Widgets read the App Group cache and nothing else.** No key from the + Keychain, no relay stream. Every record carries `carrier`, `srcState` and + the time it was read, and reads back as `cache`. +- **The session closes in the background.** iOS freezes the app anyway, + and an open socket holds one of the relay's four streams per home. - **Never hand-write a name shared with the box.** Scopes, capabilities, field ids come from `protocol/registry.yaml`, the same file as in srcfl/ftw and srcfl/ftw-webapp. ## Shared vs UI -`shared/` owns enrollment parse, rendezvous handles, Noise IK, frames, -session, vault wrap/unwrap, freshness and explanations. - -Platform UI owns the camera, the passkey ceremony, Keychain / Keystore, -and every pixel. - -Inject `PasskeyHost`, `KeyValueStore` and `SocketFactory`. Do not call -AuthenticationServices or Credential Manager from commonMain. iOS uses -Keychain. Android uses EncryptedSharedPreferences + a Keystore master key. +On iPhone, one Swift package in `appleApp/FTWKit` holds three modules: + +- `FTWKit` owns everything that is not a pixel: enrollment parse, + rendezvous handles, Noise IK, frames, the relay and Noise carriers, the + session, vault wrap/unwrap and the state each screen reads. The app alone + links it. It writes the App Group cache. +- `FTWShared` holds plain `Sendable` values for the app's extensions: the + App Group cache (`prices-`, `car-`), the price and freshness + formatters, and their Swedish and English words. Nothing on the main + actor, no keys. A copy from FTW Cloud would be one more + `CacheSource` here, not a new model. +- `FTWDemo` is the simulated box. The app links it for the demo and App + Review; FTWKit never does. + +`appleApp/FTW` owns the camera, the passkey ceremony, the Keychain and every +pixel. `appleApp/FTWWidgets` owns the widgets. Keep logic out of views: if a +sentence or a rule can be tested, it belongs in a package module with a +test. The App Group name is written once, in `appleApp/FTW.xcconfig`; the +entitlements and Info.plists read it from there. + +On Android, `shared/` owns the same logic in Kotlin. Inject `PasskeyHost`, +`KeyValueStore` and `SocketFactory`. Do not call Credential Manager from +commonMain. Android uses EncryptedSharedPreferences + a Keystore master key. ## Crypto Noise_IK_25519_ChaChaPoly_SHA256, Cacophony-tested, must stay byte-identical to the TypeScript client and the Go box. Do not swap the primitives for a -library that has not passed `NoiseTest`. +library that has not passed `NoiseTest` (Kotlin) or `NoiseTests` (Swift). +FTWKit uses CryptoKit. ## RP ID @@ -86,8 +164,15 @@ strands every passkey. ## Tests ```bash -./gradlew :shared:jvmTest +./gradlew :shared:jvmTest # Android shared logic +cd appleApp/FTWKit && swift test # iPhone logic, on a Mac with Xcode 27 ``` +`ContractTests` reads `protocol/registry.yaml` and fails when `Contract.swift` +drifts from it. Update `Contract.swift` by hand, never with a name the +registry does not have. The Apple workflow also builds the app and widget +for the iOS Simulator. Review UI changes in the simulator or on a device; +reading the source is not enough. + Green before every handoff. New protocol code needs a vector, not only a round-trip against itself. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 69ceeda..2b1673b 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -23,8 +23,10 @@ remain unchanged. See [FTW's contribution guide](https://github.com/srcfl/ftw/bl ## License of contributions -Contributions to this version use GNU AGPL v3 only with the Energyplan -combination permission in [LICENSE](LICENSE), including that permission. +Contributions to this version use GNU AGPL v3 only with both additional +permissions in [LICENSE](LICENSE): the Energyplan combination permission and +the app store distribution permission. By contributing, you grant both +permissions for your contribution. Preserve third-party licenses and notices. Sign each commit with the DCO as described in [FTW's contribution guide](https://github.com/srcfl/ftw/blob/master/CONTRIBUTING.md). A sign-off does not assign copyright or grant commercial relicensing rights. diff --git a/LICENSE b/LICENSE index 9f6b17d..e9a3955 100644 --- a/LICENSE +++ b/LICENSE @@ -2,10 +2,11 @@ FTW native app Copyright 2026 Sourceful Labs AB and contributors. This version of the project, as a whole, is licensed under the GNU Affero -General Public License, version 3 only, with the additional permission below. -Separately identified third-party material and Energyplan binaries retain -their own licenses. NOTICE preserves earlier licenses and attributions. -See LICENSING.md for scope, source access and distribution instructions. +General Public License, version 3 only, with the two additional permissions +below. Separately identified third-party material and Energyplan binaries +retain their own licenses. NOTICE preserves earlier licenses and +attributions. See LICENSING.md for scope, source access and distribution +instructions. GNU AFFERO GENERAL PUBLIC LICENSE Version 3, 19 November 2007 @@ -266,3 +267,42 @@ You may omit Energyplan and use or convey FTW under the GNU AGPL alone. No household-only or noncommercial condition applies to the AGPL-covered code. As section 7 permits, you may remove this additional permission from your copy. If you do so, the GNU AGPL applies without this permission. + +------------------------------------------------------------------------ +FTW app store distribution permission + +Additional permission under GNU AGPL version 3 section 7. + +App distribution services, such as the Apple App Store, TestFlight and +Google Play, attach their own terms to every copy they deliver. Those terms +may limit how a recipient can use, copy or install that copy, and the service +may add technical measures such as encryption or signing. The GNU AGPL +forbids further restrictions on recipients (section 10), so such terms can +conflict with it. + +For the FTW code covered by this grant, the copyright holders permit you to +convey this program, or a work based on it, in object code form through such +a service, and to accept that service's terms, even where those terms would +otherwise conflict with the GNU AGPL. This permission covers only the +restrictions that come from the service's own terms and technical measures. + +All other conditions of the GNU AGPL remain in force. In particular: + +- Offer the Corresponding Source of the exact version you convey, under the + GNU AGPL and at no charge, outside the service and free of its terms. +- Make the license text and that source offer reachable from within the + program. +- Add no restrictions of your own beyond those the service imposes. +- Provide the Installation Information that section 6 requires. You need not + provide your own signing keys, provisioning profiles or service account + credentials, as long as recipients can build the Corresponding Source and + install it on their own device with the platform's standard developer + tools. + +This permission grants no exception for proprietary modules, plugins or +derivatives of FTW. Third-party code retains its own terms; this grant does +not override them. + +If you modify this program, you may extend this permission to your version. +As section 7 permits, you may also remove it from your copy. If you do so, +the GNU AGPL applies without this permission. diff --git a/LICENSING.md b/LICENSING.md index 21756d0..59f3fa1 100644 --- a/LICENSING.md +++ b/LICENSING.md @@ -1,7 +1,8 @@ # FTW native app licensing -This version uses GNU AGPL v3 only, with the narrow Energyplan combination -permission in [LICENSE](LICENSE). [NOTICE](NOTICE) preserves prior Apache +This version uses GNU AGPL v3 only, with two narrow additional permissions in +[LICENSE](LICENSE): the Energyplan combination permission and the app store +distribution permission. [NOTICE](NOTICE) preserves prior Apache rights and attribution. Third-party files retain their own licenses. ## Use and redistribution @@ -61,8 +62,16 @@ build without a revision uses development repository links. An extracted source archive builds without Git; pass its published revision to retain the exact links. Forks must change these links to their own corresponding source. -A store's distribution terms must allow recipients the AGPL rights. This -license change does not establish compatibility with any app store agreement -or grant an exception to store restrictions. Resolve conflicting terms before -distributing through that store, using separate rights only where the rights -holders can grant them. +App stores attach their own terms to each copy. The app store distribution +permission in LICENSE lets you convey FTW through a store such as the App +Store, TestFlight or Google Play despite those terms. Offer the exact +version's Corresponding Source outside the store, make the license and the +source offer reachable in the app, and add no restrictions of your own. +Recipients must be able to build that source and install it on their own +device with the platform's standard developer tools; your signing keys and +store accounts stay yours. + +The permission covers code whose rights holders granted it. Every +contribution to this repository comes with it, as CONTRIBUTING.md says. +Third-party code keeps its own terms, so check that each dependency allows +store distribution before you add it. diff --git a/README.md b/README.md index 5ae3564..c782ee7 100644 --- a/README.md +++ b/README.md @@ -2,9 +2,10 @@ Your home's energy, on the phone. -Native iOS (SwiftUI) and Android (Jetpack Compose). Shared logic is Kotlin -Multiplatform: pairing, passkeys, Noise, the relay, the session. The box at -home is the record. This app is a cached projection of it. The cloud is blind. +Two native apps. The iPhone app is pure Swift in `appleApp/`. The Android app +is Jetpack Compose on Kotlin Multiplatform (`shared/`, `androidApp/`). Both +carry pairing, passkeys, Noise, the relay and the session. The box at home is +the record. This app is a cached projection of it. The cloud is blind. Not a wrap of the [web app](https://github.com/srcfl/ftw-webapp). Same protocol, same QR, same relay, same RP ID (`app.ftw.energy`). @@ -25,33 +26,99 @@ See [CONTRIBUTING.md](CONTRIBUTING.md). ## Shape ``` -SwiftUI / Compose - │ - ▼ - shared (KMP) — enrollment, vault, Noise IK, frames, session, relay - │ - ▼ - wss://relay.ftw.energy (encrypted) - │ - ▼ - FTW box + iPhone (appleApp/) Android + SwiftUI app ── widgets Compose + │ │ │ + ▼ ▼ ▼ + FTWKit ──► FTWShared ◄── App Group shared (KMP) + session, cache records, enrollment, vault, + Noise, vault formatters Noise IK, frames, + │ session, relay + └──────────────────┬──────────────────────┘ + ▼ + wss://relay.ftw.energy (encrypted) + │ + ▼ + FTW box ``` Two taps: scan the QR on the box, Face ID / biometrics, the house. -## Status (2026-08-22) +## Status: iPhone (2026-10-12) + +M1 is pairing, a lean Now, a car view and price widgets. This is its +foundation, taken from the Swift draft in #5 and cut down to that scope. +iOS 26 is the minimum. iPhone only. + +**In the app** + +- Pair: camera QR, a link from outside shown before it is trusted (also a + pairing QR scanned with the iOS camera, as a universal link), and the live + demo against a simulated box. In the simulator, which has no camera, a + picture of the QR from Photos takes the same path. +- Now: one sentence, the energy flow, the price card with the cheapest two + hours, today's totals and the fuse. The freshness band above it keeps + carrier and source state apart. +- A price widget that reads the App Group cache and nothing else. +- The app writes `prices-` and `car-` into the App Group after + each read, with where each reading came from and when. +- The session closes when the app goes to the background and opens again + on return, without a passkey. +- A relay that turns the phone away with 4429 shows "FTW is busy" ("FTW är + upptaget") instead of an error. + +**Proven** + +| Check | Result | +|---|---| +| `swift test` in `appleApp/FTWKit` (Xcode 27, Swift 6.4) | 94 tests green, 1 live test skipped | +| Cross-implementation vectors from the web app and the box | Noise, frames, rendezvous handles and the vault copy match | +| `ContractTests` against `protocol/registry.yaml` | Scopes, ops, capabilities, modes, errors, states and frozen field ids match | +| App and widget for the iOS Simulator, signed ad hoc | Builds; both carry the App Group entitlement | +| Demo in the iOS Simulator (iPhone 17, iOS 26.5) | Launches and streams | +| A picture of a pairing QR, picked from Photos in that simulator | Read and paired; the home then dials the relay | + +**Next in M1** + +The car view (level presets 20/40/60/80 and ±5 through `loadpoint.soc.set`, +the goal through the schedule PUT, Charge now, Pause and Back to the plan), +the four price widget families, then tests and screenshots. + +**Manual steps before a phone build** + +The team is `L66M368PZ6`, set once in `appleApp/FTW.xcconfig` for the app and +the widget, with automatic signing. Nothing for signing is in the repository. +Someone in that team still has to: + +- Register the App IDs `energy.ftw.app` and `energy.ftw.app.widgets`, the + App Group `group.energy.ftw.app` on both, and Associated Domains on the + app. Signing in to Xcode with the team and building for a phone does this + through automatic signing; the developer portal works too. +- Publish `apple-app-site-association` on `app.ftw.energy` with + `L66M368PZ6.energy.ftw.app` under `webcredentials` and `/p` under + `applinks` (in ftw-webapp). Passkeys and the camera QR link need it. + +Simulator builds sign ad hoc and need no Apple account. + +**Not in M1 (do not start these next)** + +Plan, History, Box admin, escrow and recovery, spoken box codes, macOS, +LAN, WebRTC, push and the store listing. + +## Status: Android (2026-08-22) V1 is Pair + Now. That is on `main` as of 2026-08-22. Not a wrap of the web -app. Not Flutter, not React Native. +app. Not Flutter, not React Native. Android's KMP core stays as it is until +Android is back in scope. -**In the apps today** +**In the app today** - Scan or paste a v2 pairing QR (`https://app.ftw.energy/p#v2.…`). - One passkey prompt at enroll. RP ID `app.ftw.energy`. PRF salt `ftw.prf.v1.vault`. - Noise_IK_25519_ChaChaPoly_SHA256 to the box through `wss://relay.ftw.energy`. - Now shows headline plus grid / solar / battery / house from frozen field ids. -- Vault, site and last readings live in iOS Keychain / - Android EncryptedSharedPreferences. Cold start paints from cache, then +- Vault, site and last readings live in Android + EncryptedSharedPreferences. Cold start paints from cache, then reconnects without Face ID. Forget wipes the store. **Proven here** @@ -60,24 +127,24 @@ app. Not Flutter, not React Native. |---|---| | `./gradlew :shared:jvmTest` | Green | | Live box e2e (`127.0.0.1:18080` + production relay) | `hello_ok` + snapshot, phase `streaming` | -| iOS Simulator (iPhone 17, iOS 26.5) | Built and launched | | Android emulator `FTW_Phone` (API 35 ARM64) | APK installed, Pair shown twice | Passkey PRF cannot run on the JVM. Live e2e uses a local wrapping key for the ceremony and the real Noise / relay / box path. The Android emulator has no camera feed — paste the pairing link. -**Not v1 (do not start these next)** +**Not v1 on Android (do not start these next)** Energy, History, Plan, EV, commands, escrow restore, spoken codes, LAN, -WebRTC, push, App Store / Play listing. +WebRTC, push, Play listing. **Known holes** - `srcState` should follow the Now fields' `srcId` in the dict, not every driver on the site. -- Wrap key is raw PRF bytes, not the web app's HKDF. A native vault will not - open in the PWA, and the other way around. +- Wrap key is raw PRF bytes, not the web app's HKDF. An Android vault will + not open in the PWA, and the other way around. The iPhone app uses the + web app's HKDF. - `PasskeyHost.enroll` from Kotlin still blocks. The UIs call the async ceremony and skip that path. - Field ids in `Explanation.kt` are still hand-written; they should come from @@ -85,14 +152,20 @@ WebRTC, push, App Store / Play listing. ## Tests -JDK 21. +iPhone, on a Mac with Xcode 27: + +```bash +cd appleApp/FTWKit && swift test +``` + +Android, JDK 21: ```bash export JAVA_HOME="$(brew --prefix openjdk@21)/libexec/openjdk.jdk/Contents/Home" ./gradlew :shared:jvmTest ``` -That suite covers enrollment URLs, Cacophony Noise IK, hello/sub CBOR matching +The Android suite covers enrollment URLs, Cacophony Noise IK, hello/sub CBOR matching the box's interop hex, 512-byte lane 0 frames, the vault, and a session that turns hello_ok + snap into Now state. @@ -116,10 +189,18 @@ snapshot that includes the frozen field ids. ## Native apps -iOS: open `iosApp/iosApp.xcodeproj`. SwiftUI Pair (camera QR + paste) and Now. -Xcode 16+, iOS 18 for passkey PRF. A Run Script build phase compiles the -Shared framework with -`./gradlew :shared:embedAndSignAppleFrameworkForXcode`. +iPhone: open `appleApp/FTW.xcodeproj` in Xcode 27 and run the FTW scheme on +a simulator. The demo on the pairing screen runs without a box, a passkey or +a network; `-FTWDemo YES` opens it at launch in a debug build. From the +command line: + +```bash +cd appleApp +xcodebuild build -project FTW.xcodeproj -scheme FTW -destination 'generic/platform=iOS Simulator' +``` + +Simulator builds sign ad hoc (`FTW.xcconfig`), so the App Group entitlement +travels with the app and its widget. Android: `./gradlew :androidApp:assembleDebug` (minSdk 28). Pair uses CameraX + ML Kit for the QR. Passkeys go through Credential Manager. @@ -140,14 +221,24 @@ wrapping copy so Now paints without a passkey prompt. | Path | What | |---|---| -| `shared/` | KMP: identity, crypto, protocol, relay, session | +| `appleApp/FTWKit/Sources/FTWKit` | Swift: identity, crypto, protocol, relay, session, screen state. The app alone links it. | +| `appleApp/FTWKit/Sources/FTWShared` | `Sendable` values for the app and its extensions: the App Group cache and the price and freshness formatters | +| `appleApp/FTWKit/Sources/FTWDemo` | The simulated box, for the demo, App Review and previews | +| `appleApp/FTW/` | SwiftUI for iPhone, Keychain, passkeys, camera | +| `appleApp/FTWWidgets/` | WidgetKit extension. Reads the cache; no key, no relay. | +| `appleApp/FTW.xcconfig` | Settings app and widget share: the App Group, versions, signing | +| `shared/` | KMP for Android: identity, crypto, protocol, relay, session | | `androidApp/` | Compose UI | -| `iosApp/` | SwiftUI UI | | `protocol/registry.yaml` | Names shared with the box | -| `scripts/e2e-ftw.sh` | Live box e2e | +| `scripts/e2e-ftw.sh` | Live box e2e (Android shared logic) | + +The Xcode project uses folders that Xcode keeps in step with the disk. A new +file under `FTW/`, `FTWWidgets/` or a package target needs no change to +`project.pbxproj`; only a new target does. ## Licence -AGPL-3.0-only with the Energyplan combination permission. See -[LICENSE](LICENSE), [LICENSING.md](LICENSING.md) and [NOTICE](NOTICE). +AGPL-3.0-only with the Energyplan combination permission and an app store +distribution permission. See [LICENSE](LICENSE), [LICENSING.md](LICENSING.md) +and [NOTICE](NOTICE). Earlier Apache-licensed versions retain their earlier grants. diff --git a/appleApp/ExportOptions.plist b/appleApp/ExportOptions.plist new file mode 100644 index 0000000..62896d2 --- /dev/null +++ b/appleApp/ExportOptions.plist @@ -0,0 +1,24 @@ + + + + + + method + app-store-connect + destination + upload + teamID + L66M368PZ6 + signingStyle + automatic + manageAppVersionAndBuildNumber + + uploadSymbols + + + diff --git a/appleApp/FTW-Info.plist b/appleApp/FTW-Info.plist new file mode 100644 index 0000000..2aff52e --- /dev/null +++ b/appleApp/FTW-Info.plist @@ -0,0 +1,28 @@ + + + + + FTWAppGroup + $(FTW_APP_GROUP) + BGTaskSchedulerPermittedIdentifiers + + energy.ftw.app.prices + + UIBackgroundModes + + fetch + + CFBundleLocalizations + + en + sv + + ITSAppUsesNonExemptEncryption + + UILaunchScreen + + UIColorName + LaunchBackground + + + diff --git a/iosApp/iosApp/iosApp.entitlements b/appleApp/FTW.entitlements similarity index 76% rename from iosApp/iosApp/iosApp.entitlements rename to appleApp/FTW.entitlements index fbe5ddb..1411e58 100644 --- a/iosApp/iosApp/iosApp.entitlements +++ b/appleApp/FTW.entitlements @@ -7,5 +7,9 @@ webcredentials:app.ftw.energy applinks:app.ftw.energy + com.apple.security.application-groups + + $(FTW_APP_GROUP) + diff --git a/appleApp/FTW.xcconfig b/appleApp/FTW.xcconfig new file mode 100644 index 0000000..e4a5a20 --- /dev/null +++ b/appleApp/FTW.xcconfig @@ -0,0 +1,30 @@ +// Settings the app and its widget extension must agree on, in one place. +// Both targets build on this file through the project. + +// The App Group the app and widgets share. Written here once: both +// entitlements files and both Info.plists read it as $(FTW_APP_GROUP), and +// Swift reads it back from Info.plist (AppGroupFiles.infoKey). +FTW_APP_GROUP = group.energy.ftw.app + +// An extension's version must match its app's. +MARKETING_VERSION = 0.1.0 +CURRENT_PROJECT_VERSION = 1 + +IPHONEOS_DEPLOYMENT_TARGET = 26.0 + +// Sourceful's Apple team, for the app and the widget alike. Signing is +// automatic: Xcode, signed in to that team, makes the profiles. Nothing for +// signing is committed here: no certificate, profile or key. +DEVELOPMENT_TEAM = L66M368PZ6 +CODE_SIGN_STYLE = Automatic + +// Simulator builds sign ad hoc and need no Apple account, for CI and for +// anyone without one. The entitlements, and with them the App Group, still +// travel with the app. +CODE_SIGN_IDENTITY[sdk=iphonesimulator*] = - + +// The commit Source & licenses links to, which the license asks the app to +// offer. CI passes the full commit (xcodebuild … FTW_SOURCE_REVISION=$GITHUB_SHA). +// Left empty, the "Record the source revision" phase asks git, and a tree +// with changes is a local build that links to the repository instead. +FTW_SOURCE_REVISION = diff --git a/appleApp/FTW.xcodeproj/project.pbxproj b/appleApp/FTW.xcodeproj/project.pbxproj new file mode 100644 index 0000000..f3b1b06 --- /dev/null +++ b/appleApp/FTW.xcodeproj/project.pbxproj @@ -0,0 +1,525 @@ +// !$*UTF8*$! +{ + archiveVersion = 1; + classes = { + }; + objectVersion = 77; + objects = { + +/* Begin PBXBuildFile section */ + F7A000000000000000000014 /* FTWKit in Frameworks */ = {isa = PBXBuildFile; productRef = F7A000000000000000000016 /* FTWKit */; }; + F7A000000000000000000030 /* FTWDemo in Frameworks */ = {isa = PBXBuildFile; productRef = F7A000000000000000000031 /* FTWDemo */; }; + F7A000000000000000000032 /* FTWShared in Frameworks */ = {isa = PBXBuildFile; productRef = F7A000000000000000000033 /* FTWShared */; }; + F7A000000000000000000064 /* LICENSE in Copy the license texts */ = {isa = PBXBuildFile; fileRef = F7A000000000000000000060 /* LICENSE */; }; + F7A000000000000000000065 /* NOTICE in Copy the license texts */ = {isa = PBXBuildFile; fileRef = F7A000000000000000000061 /* NOTICE */; }; + F7A000000000000000000066 /* LICENSING.md in Copy the license texts */ = {isa = PBXBuildFile; fileRef = F7A000000000000000000062 /* LICENSING.md */; }; + F7A000000000000000000034 /* FTWWidgets.appex in Embed Foundation Extensions */ = {isa = PBXBuildFile; fileRef = F7A000000000000000000040 /* FTWWidgets.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; }; +/* End PBXBuildFile section */ + +/* Begin PBXContainerItemProxy section */ + F7A000000000000000000035 /* PBXContainerItemProxy */ = { + isa = PBXContainerItemProxy; + containerPortal = F7A000000000000000000001 /* Project object */; + proxyType = 1; + remoteGlobalIDString = F7A000000000000000000050; + remoteInfo = FTWWidgets; + }; +/* End PBXContainerItemProxy section */ + +/* Begin PBXCopyFilesBuildPhase section */ + F7A000000000000000000036 /* Embed Foundation Extensions */ = { + isa = PBXCopyFilesBuildPhase; + buildActionMask = 2147483647; + dstPath = ""; + dstSubfolderSpec = 13; + files = ( + F7A000000000000000000034 /* FTWWidgets.appex in Embed Foundation Extensions */, + ); + name = "Embed Foundation Extensions"; + runOnlyForDeploymentPostprocessing = 0; + }; + F7A000000000000000000067 /* Copy the license texts */ = { + isa = PBXCopyFilesBuildPhase; + buildActionMask = 2147483647; + dstPath = Licenses; + dstSubfolderSpec = 7; + files = ( + F7A000000000000000000064 /* LICENSE in Copy the license texts */, + F7A000000000000000000065 /* NOTICE in Copy the license texts */, + F7A000000000000000000066 /* LICENSING.md in Copy the license texts */, + ); + name = "Copy the license texts"; + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXCopyFilesBuildPhase section */ + +/* Begin PBXFileReference section */ + F7A000000000000000000005 /* FTW.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = FTW.app; sourceTree = BUILT_PRODUCTS_DIR; }; + F7A000000000000000000006 /* FTW.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = FTW.entitlements; sourceTree = ""; }; + F7A000000000000000000007 /* FTWWidgets.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = FTWWidgets.entitlements; sourceTree = ""; }; + F7A000000000000000000008 /* FTW.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = FTW.xcconfig; sourceTree = ""; }; + F7A000000000000000000009 /* FTW-Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = "FTW-Info.plist"; sourceTree = ""; }; + F7A00000000000000000000A /* FTWWidgets-Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = "FTWWidgets-Info.plist"; sourceTree = ""; }; + F7A000000000000000000060 /* LICENSE */ = {isa = PBXFileReference; lastKnownFileType = text; name = LICENSE; path = ../LICENSE; sourceTree = SOURCE_ROOT; }; + F7A000000000000000000061 /* NOTICE */ = {isa = PBXFileReference; lastKnownFileType = text; name = NOTICE; path = ../NOTICE; sourceTree = SOURCE_ROOT; }; + F7A000000000000000000062 /* LICENSING.md */ = {isa = PBXFileReference; lastKnownFileType = net.daringfireball.markdown; name = LICENSING.md; path = ../LICENSING.md; sourceTree = SOURCE_ROOT; }; + F7A000000000000000000040 /* FTWWidgets.appex */ = {isa = PBXFileReference; explicitFileType = "wrapper.app-extension"; includeInIndex = 0; path = FTWWidgets.appex; sourceTree = BUILT_PRODUCTS_DIR; }; +/* End PBXFileReference section */ + +/* Begin PBXFileSystemSynchronizedRootGroup section */ + F7A000000000000000000003 /* FTW */ = { + isa = PBXFileSystemSynchronizedRootGroup; + path = FTW; + sourceTree = ""; + }; + F7A000000000000000000041 /* FTWWidgets */ = { + isa = PBXFileSystemSynchronizedRootGroup; + path = FTWWidgets; + sourceTree = ""; + }; +/* End PBXFileSystemSynchronizedRootGroup section */ + +/* Begin PBXFrameworksBuildPhase section */ + F7A000000000000000000012 /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + F7A000000000000000000014 /* FTWKit in Frameworks */, + F7A000000000000000000030 /* FTWDemo in Frameworks */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; + F7A000000000000000000052 /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + F7A000000000000000000032 /* FTWShared in Frameworks */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXFrameworksBuildPhase section */ + +/* Begin PBXGroup section */ + F7A000000000000000000002 = { + isa = PBXGroup; + children = ( + F7A000000000000000000003 /* FTW */, + F7A000000000000000000041 /* FTWWidgets */, + F7A000000000000000000008 /* FTW.xcconfig */, + F7A000000000000000000006 /* FTW.entitlements */, + F7A000000000000000000009 /* FTW-Info.plist */, + F7A000000000000000000007 /* FTWWidgets.entitlements */, + F7A00000000000000000000A /* FTWWidgets-Info.plist */, + F7A000000000000000000063 /* License texts */, + F7A000000000000000000004 /* Products */, + ); + sourceTree = ""; + }; + F7A000000000000000000063 /* License texts */ = { + isa = PBXGroup; + children = ( + F7A000000000000000000060 /* LICENSE */, + F7A000000000000000000061 /* NOTICE */, + F7A000000000000000000062 /* LICENSING.md */, + ); + name = "License texts"; + sourceTree = ""; + }; + F7A000000000000000000004 /* Products */ = { + isa = PBXGroup; + children = ( + F7A000000000000000000005 /* FTW.app */, + F7A000000000000000000040 /* FTWWidgets.appex */, + ); + 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 */, + F7A000000000000000000067 /* Copy the license texts */, + F7A000000000000000000068 /* Record the source revision */, + F7A000000000000000000036 /* Embed Foundation Extensions */, + ); + buildRules = ( + ); + dependencies = ( + F7A000000000000000000037 /* PBXTargetDependency */, + ); + fileSystemSynchronizedGroups = ( + F7A000000000000000000003 /* FTW */, + ); + name = FTW; + packageProductDependencies = ( + F7A000000000000000000016 /* FTWKit */, + F7A000000000000000000031 /* FTWDemo */, + ); + productName = FTW; + productReference = F7A000000000000000000005 /* FTW.app */; + productType = "com.apple.product-type.application"; + }; + F7A000000000000000000050 /* FTWWidgets */ = { + isa = PBXNativeTarget; + buildConfigurationList = F7A000000000000000000056 /* Build configuration list for PBXNativeTarget "FTWWidgets" */; + buildPhases = ( + F7A000000000000000000051 /* Sources */, + F7A000000000000000000052 /* Frameworks */, + F7A000000000000000000053 /* Resources */, + ); + buildRules = ( + ); + dependencies = ( + ); + fileSystemSynchronizedGroups = ( + F7A000000000000000000041 /* FTWWidgets */, + ); + name = FTWWidgets; + packageProductDependencies = ( + F7A000000000000000000033 /* FTWShared */, + ); + productName = FTWWidgets; + productReference = F7A000000000000000000040 /* FTWWidgets.appex */; + productType = "com.apple.product-type.app-extension"; + }; +/* End PBXNativeTarget section */ + +/* Begin PBXProject section */ + F7A000000000000000000001 /* Project object */ = { + isa = PBXProject; + attributes = { + BuildIndependentTargetsInParallel = 1; + LastSwiftUpdateCheck = 1600; + LastUpgradeCheck = 1600; + TargetAttributes = { + F7A000000000000000000010 = { + CreatedOnToolsVersion = 16.0; + }; + F7A000000000000000000050 = { + CreatedOnToolsVersion = 27.0; + }; + }; + }; + buildConfigurationList = F7A000000000000000000020 /* Build configuration list for PBXProject "FTW" */; + developmentRegion = en; + hasScannedForEncodings = 0; + knownRegions = ( + en, + sv, + Base, + ); + mainGroup = F7A000000000000000000002; + minimizedProjectReferenceProxies = 1; + packageReferences = ( + F7A000000000000000000015 /* XCLocalSwiftPackageReference "FTWKit" */, + ); + preferredProjectObjectVersion = 77; + productRefGroup = F7A000000000000000000004 /* Products */; + projectDirPath = ""; + projectRoot = ""; + targets = ( + F7A000000000000000000010 /* FTW */, + F7A000000000000000000050 /* FTWWidgets */, + ); + }; +/* End PBXProject section */ + +/* Begin PBXResourcesBuildPhase section */ + F7A000000000000000000013 /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; + F7A000000000000000000053 /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXResourcesBuildPhase section */ + +/* Begin PBXShellScriptBuildPhase section */ + F7A000000000000000000068 /* Record the source revision */ = { + isa = PBXShellScriptBuildPhase; + alwaysOutOfDate = 1; + buildActionMask = 2147483647; + files = ( + ); + inputFileListPaths = ( + ); + inputPaths = ( + ); + name = "Record the source revision"; + outputFileListPaths = ( + ); + outputPaths = ( + "$(TARGET_BUILD_DIR)/$(UNLOCALIZED_RESOURCES_FOLDER_PATH)/Licenses/REVISION", + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "# The commit Source & licenses links to. CI passes FTW_SOURCE_REVISION\n# (GITHUB_SHA). Otherwise git says. A tree with changes, or no git at all,\n# is a local build, and the screen says so.\nset -eu\nout=\"$SCRIPT_OUTPUT_FILE_0\"\nmkdir -p \"$(dirname \"$out\")\"\nrev=\"${FTW_SOURCE_REVISION:-}\"\nif [ -n \"$rev\" ]; then\n if [ ${#rev} -ne 40 ] || [ -n \"$(printf '%s' \"$rev\" | tr -d '0-9a-f')\" ]; then\n echo \"error: FTW_SOURCE_REVISION must be a full 40-character commit, not '$rev'\"\n exit 1\n fi\nelif head=$(git -C \"$SRCROOT\" rev-parse --verify HEAD 2>/dev/null) &&\n changes=$(git -C \"$SRCROOT\" --no-optional-locks status --porcelain 2>/dev/null); then\n rev=\"$head\"\n if [ -n \"$changes\" ]; then rev=\"$head-dirty\"; fi\nfi\nprintf '%s' \"$rev\" > \"$out\"\necho \"Source revision: ${rev:-unknown}\"\n"; + showEnvVarsInLog = 0; + }; +/* End PBXShellScriptBuildPhase section */ + +/* Begin PBXSourcesBuildPhase section */ + F7A000000000000000000011 /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; + F7A000000000000000000051 /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXSourcesBuildPhase section */ + +/* Begin PBXTargetDependency section */ + F7A000000000000000000037 /* PBXTargetDependency */ = { + isa = PBXTargetDependency; + target = F7A000000000000000000050 /* FTWWidgets */; + targetProxy = F7A000000000000000000035 /* PBXContainerItemProxy */; + }; +/* End PBXTargetDependency section */ + +/* Begin XCBuildConfiguration section */ + F7A000000000000000000021 /* Debug */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = F7A000000000000000000008 /* FTW.xcconfig */; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; + 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)", + ); + LOCALIZATION_PREFERS_STRING_CATALOGS = YES; + MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE; + MTL_FAST_MATH = YES; + ONLY_ACTIVE_ARCH = YES; + SDKROOT = iphoneos; + SWIFT_ACTIVE_COMPILATION_CONDITIONS = "DEBUG $(inherited)"; + SWIFT_OPTIMIZATION_LEVEL = "-Onone"; + }; + name = Debug; + }; + F7A000000000000000000022 /* Release */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = F7A000000000000000000008 /* FTW.xcconfig */; + 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; + LOCALIZATION_PREFERS_STRING_CATALOGS = YES; + MTL_ENABLE_DEBUG_INFO = NO; + MTL_FAST_MATH = YES; + SDKROOT = iphoneos; + SWIFT_COMPILATION_MODE = wholemodule; + VALIDATE_PRODUCT = YES; + }; + name = Release; + }; + F7A000000000000000000024 /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; + CODE_SIGN_ENTITLEMENTS = FTW.entitlements; + ENABLE_PREVIEWS = YES; + GENERATE_INFOPLIST_FILE = YES; + INFOPLIST_FILE = "FTW-Info.plist"; + INFOPLIST_KEY_CFBundleDisplayName = FTW; + 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_iPhone = UIInterfaceOrientationPortrait; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + ); + PRODUCT_BUNDLE_IDENTIFIER = energy.ftw.app; + PRODUCT_NAME = FTW; + SUPPORTED_PLATFORMS = "iphoneos iphonesimulator"; + SUPPORTS_MACCATALYST = NO; + SUPPORTS_MAC_DESIGNED_FOR_IPHONE_IPAD = NO; + SUPPORTS_XR_DESIGNED_FOR_IPHONE_IPAD = NO; + SWIFT_EMIT_LOC_STRINGS = YES; + SWIFT_VERSION = 6.0; + TARGETED_DEVICE_FAMILY = 1; + }; + name = Debug; + }; + F7A000000000000000000025 /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; + CODE_SIGN_ENTITLEMENTS = FTW.entitlements; + ENABLE_PREVIEWS = YES; + GENERATE_INFOPLIST_FILE = YES; + INFOPLIST_FILE = "FTW-Info.plist"; + INFOPLIST_KEY_CFBundleDisplayName = FTW; + 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_iPhone = UIInterfaceOrientationPortrait; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + ); + PRODUCT_BUNDLE_IDENTIFIER = energy.ftw.app; + PRODUCT_NAME = FTW; + SUPPORTED_PLATFORMS = "iphoneos iphonesimulator"; + SUPPORTS_MACCATALYST = NO; + SUPPORTS_MAC_DESIGNED_FOR_IPHONE_IPAD = NO; + SUPPORTS_XR_DESIGNED_FOR_IPHONE_IPAD = NO; + SWIFT_EMIT_LOC_STRINGS = YES; + SWIFT_VERSION = 6.0; + TARGETED_DEVICE_FAMILY = 1; + }; + name = Release; + }; + F7A000000000000000000054 /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + CODE_SIGN_ENTITLEMENTS = FTWWidgets.entitlements; + GENERATE_INFOPLIST_FILE = YES; + INFOPLIST_FILE = "FTWWidgets-Info.plist"; + INFOPLIST_KEY_CFBundleDisplayName = FTW; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + "@executable_path/../../Frameworks", + ); + PRODUCT_BUNDLE_IDENTIFIER = energy.ftw.app.widgets; + PRODUCT_NAME = FTWWidgets; + SKIP_INSTALL = YES; + SUPPORTED_PLATFORMS = "iphoneos iphonesimulator"; + SUPPORTS_MACCATALYST = NO; + SWIFT_EMIT_LOC_STRINGS = YES; + SWIFT_VERSION = 6.0; + TARGETED_DEVICE_FAMILY = 1; + }; + name = Debug; + }; + F7A000000000000000000055 /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + CODE_SIGN_ENTITLEMENTS = FTWWidgets.entitlements; + GENERATE_INFOPLIST_FILE = YES; + INFOPLIST_FILE = "FTWWidgets-Info.plist"; + INFOPLIST_KEY_CFBundleDisplayName = FTW; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + "@executable_path/../../Frameworks", + ); + PRODUCT_BUNDLE_IDENTIFIER = energy.ftw.app.widgets; + PRODUCT_NAME = FTWWidgets; + SKIP_INSTALL = YES; + SUPPORTED_PLATFORMS = "iphoneos iphonesimulator"; + SUPPORTS_MACCATALYST = NO; + SWIFT_EMIT_LOC_STRINGS = YES; + SWIFT_VERSION = 6.0; + TARGETED_DEVICE_FAMILY = 1; + }; + name = Release; + }; +/* 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; + }; + F7A000000000000000000056 /* Build configuration list for PBXNativeTarget "FTWWidgets" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + F7A000000000000000000054 /* Debug */, + F7A000000000000000000055 /* 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; + }; + F7A000000000000000000031 /* FTWDemo */ = { + isa = XCSwiftPackageProductDependency; + package = F7A000000000000000000015 /* XCLocalSwiftPackageReference "FTWKit" */; + productName = FTWDemo; + }; + F7A000000000000000000033 /* FTWShared */ = { + isa = XCSwiftPackageProductDependency; + package = F7A000000000000000000015 /* XCLocalSwiftPackageReference "FTWKit" */; + productName = FTWShared; + }; +/* End XCSwiftPackageProductDependency section */ + }; + rootObject = F7A000000000000000000001 /* Project object */; +} diff --git a/appleApp/FTW.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/About/AboutLink.swift b/appleApp/FTW/About/AboutLink.swift new file mode 100644 index 0000000..65d2de1 --- /dev/null +++ b/appleApp/FTW/About/AboutLink.swift @@ -0,0 +1,60 @@ +import FTWKit +import SwiftUI + +/// The way to Source & licenses, the privacy policy and removing this phone, +/// at the foot of Now and of pairing. The license asks that its text and the +/// source offer be reachable from inside the app; App Review asks the same +/// of the privacy policy (5.1.1(i)) and of removing what the app set up. +struct AboutLink: View { + let app: AppModel + @State private var open: AboutView.Start? + + var body: some View { + VStack(spacing: 6) { + // One line while it fits, one link to a line when it does not. + ViewThatFits(in: .horizontal) { + HStack(spacing: 20) { links } + VStack(spacing: 8) { links } + } + .font(.footnote.weight(.medium)) + .foregroundStyle(Theme.accent) + .buttonStyle(.plain) + Text(verbatim: "FTW \(BuildSource.bundled.versionText)") + .font(.caption) + .foregroundStyle(Theme.fgMuted) + } + .multilineTextAlignment(.center) + .frame(maxWidth: .infinity) + .padding(.top, 28) + .padding(.bottom, 8) + .sheet(item: $open) { start in + AboutView(app: app, start: start) + } + #if DEBUG + // For screenshots: `-FTWAbout about`, `remove` or a file name such + // as `LICENSE` opens Source & licenses there. + .task { + if let name = UserDefaults.standard.string(forKey: "FTWAbout") { open = AboutView.Start(name) } + } + #endif + } + + @ViewBuilder private var links: some View { + Button(AboutWords.text("Source & licenses")) { open = .about } + Link(AboutWords.text("Privacy"), destination: Origin.privacyURL()) + if AboutView.canRemove(app) { + Button(AboutWords.text("Remove this phone")) { open = .remove } + } + } +} + +extension BuildSource { + /// This build, as its bundle records it. The "Record the source revision" + /// phase writes the commit; CI sets FTW_SOURCE_REVISION to GITHUB_SHA. + static let bundled = BuildSource( + version: AppInfo.version, + build: AppInfo.buildNumber, + recorded: Bundle.main.url(forResource: "REVISION", withExtension: nil, subdirectory: "Licenses") + .flatMap { try? String(contentsOf: $0, encoding: .utf8) } + ) +} diff --git a/appleApp/FTW/About/AboutView.swift b/appleApp/FTW/About/AboutView.swift new file mode 100644 index 0000000..78906e1 --- /dev/null +++ b/appleApp/FTW/About/AboutView.swift @@ -0,0 +1,167 @@ +import FTWKit +import SwiftUI + +/// Source & licenses: what this build is, where its exact source is, and +/// the license texts the build copied in from the repository root. +struct AboutView: View { + enum Start: Hashable, Identifiable { + case about, remove + case document(LicenseText.Document) + + var id: Self { self } + + init?(_ name: String) { + switch name { + case "about": self = .about + case "remove": self = .remove + default: + guard let document = LicenseText.Document(rawValue: name) else { return nil } + self = .document(document) + } + } + } + + enum Route: Hashable { + case document(LicenseText.Document) + case remove + } + + let app: AppModel + @State private var path: [Route] + @Environment(\.dismiss) private var dismiss + @Environment(\.dynamicTypeSize) private var typeSize + @ScaledMetric(relativeTo: .title2) private var markSize: CGFloat = 52 + private let source = BuildSource.bundled + + init(app: AppModel, start: Start = .about) { + self.app = app + switch start { + case .about: _path = State(initialValue: []) + case .remove: _path = State(initialValue: [.remove]) + case .document(let document): _path = State(initialValue: [.document(document)]) + } + } + + /// A paired phone has something to remove. The demo has not, and its + /// way out is Exit demo. + static func canRemove(_ app: AppModel) -> Bool { + !app.isDemo && (app.vault.isEnrolled || app.sites.current() != nil) + } + + var body: some View { + NavigationStack(path: $path) { + List { + Section { header } + + Section { + LabeledContent(AboutWords.text("Version"), value: source.version) + LabeledContent(AboutWords.text("Build"), value: source.build) + LabeledContent(AboutWords.text("Commit")) { + Text(source.revisionText) + .font(source.isLocal ? .body : .body.monospaced()) + .textSelection(.enabled) + } + } header: { + Text(AboutWords.text("This version")) + } footer: { + if let note = source.localNote { Text(note) } + } + + Section { + Link(destination: source.sourceURL) { + Label(source.isLocal ? AboutWords.text("Source code on GitHub") : AboutWords.text("Source code of this version"), systemImage: "chevron.left.forwardslash.chevron.right") + } + Link(destination: source.licenseURL) { + Label(source.isLocal ? AboutWords.text("License on GitHub") : AboutWords.text("License at this commit"), systemImage: "doc.text") + } + } header: { + Text(AboutWords.text("Source code")) + } footer: { + Text(AboutWords.text("FTW is free software. You may run, study, change and share it under the GNU AGPL v3 with the two additional permissions in LICENSE. The link gives the complete source of this exact version, at no charge.")) + } + + Section { + Link(destination: Origin.privacyURL()) { + Label(AboutWords.text("Privacy policy"), systemImage: "hand.raised") + } + Link(destination: Origin.supportURL()) { + Label(AboutWords.text("Support"), systemImage: "questionmark.circle") + } + } header: { + Text(AboutWords.text("Privacy and support")) + } footer: { + Text(AboutWords.text("FTW collects nothing about you. Readings and commands travel encrypted between this phone and your box, and Sourceful cannot read them.")) + } + + Section { + ForEach(LicenseText.Document.allCases) { document in + NavigationLink(value: Route.document(document)) { + VStack(alignment: .leading, spacing: 2) { + Text(document.title) + Text(document.rawValue) + .font(.caption.monospaced()) + .foregroundStyle(Theme.fgMuted) + } + } + } + } header: { + Text(AboutWords.text("Licenses")) + } footer: { + Text(verbatim: "\(AboutWords.text("Third-party code")): \(AboutWords.text("None. FTW uses only Apple's own frameworks."))") + } + + if Self.canRemove(app) { + Section(AboutWords.text("This phone")) { + NavigationLink(value: Route.remove) { + Text(AboutWords.text("Remove this phone")) + .foregroundStyle(Theme.importing) + } + } + } + } + .scrollContentBackground(.hidden) + .background(Theme.surface) + .navigationTitle(AboutWords.text("Source & licenses")) + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .topBarTrailing) { + Button(role: .close) { dismiss() } + .tint(nil) + } + } + .navigationDestination(for: Route.self) { route in + switch route { + case .document(let document): + LicenseView(document: document, source: source) + case .remove: + RemovePhoneView(app: app) { dismiss() } + } + } + } + .tint(Theme.accent) + } + + /// The notices the AGPL asks an interactive program to show: who holds + /// the copyright, that there is no warranty, and where the license is. + private var header: some View { + let stack = typeSize.isAccessibilitySize ? AnyLayout(VStackLayout(alignment: .leading, spacing: 12)) : AnyLayout(HStackLayout(alignment: .top, spacing: 14)) + return stack { + Image(systemName: "bolt.fill") + .font(.title2) + .foregroundStyle(Theme.accent) + .frame(width: markSize, height: markSize) + .background(Color(white: 0.05), in: RoundedRectangle(cornerRadius: markSize * 0.23, style: .continuous)) + .accessibilityHidden(true) + VStack(alignment: .leading, spacing: 4) { + Text("FTW").font(.title2.weight(.bold)) + Text(AboutWords.text("© 2026 Sourceful Labs AB and contributors.")) + .font(.footnote) + Text(AboutWords.text("There is no warranty, to the extent the law allows.")) + .font(.footnote) + .foregroundStyle(Theme.fgDim) + } + } + .padding(.vertical, 4) + .accessibilityElement(children: .combine) + } +} diff --git a/appleApp/FTW/About/LicenseView.swift b/appleApp/FTW/About/LicenseView.swift new file mode 100644 index 0000000..716eb53 --- /dev/null +++ b/appleApp/FTW/About/LicenseView.swift @@ -0,0 +1,65 @@ +import FTWKit +import SwiftUI + +/// One license file, as the build copied it into the app from the +/// repository root, with its lines joined for a phone's width. +struct LicenseView: View { + let document: LicenseText.Document + let source: BuildSource + private let raw: String? + private let blocks: [LicenseText.Block] + + init(document: LicenseText.Document, source: BuildSource) { + self.document = document + self.source = source + raw = Bundle.main.url(forResource: document.rawValue, withExtension: nil, subdirectory: "Licenses") + .flatMap { try? String(contentsOf: $0, encoding: .utf8) } + blocks = raw.map { LicenseText.blocks($0, markdown: document.isMarkdown) } ?? [] + } + + var body: some View { + ScrollView { + LazyVStack(alignment: .leading, spacing: 12) { + if raw == nil { + Text(AboutWords.text("This text is missing from this build.")) + .foregroundStyle(Theme.importing) + } + ForEach(blocks) { block in + row(block) + } + } + .textSelection(.enabled) + .padding(16) + .frame(maxWidth: 640, alignment: .leading) + .frame(maxWidth: .infinity) + } + .background(Theme.surface) + .navigationTitle(document.title) + .navigationBarTitleDisplayMode(.inline) + .toolbar { + if let raw { + ToolbarItem(placement: .primaryAction) { + ShareLink(item: raw, preview: SharePreview(document.rawValue)) + } + } + } + } + + @ViewBuilder private func row(_ block: LicenseText.Block) -> some View { + switch block.kind { + case .heading(let level): + Text(block.text) + .font(level == 1 ? .title3.weight(.semibold) : .headline) + .foregroundStyle(Theme.fg) + .padding(.top, 8) + .accessibilityAddTraits(.isHeader) + case .rule: + Divider().overlay(Theme.line) + case .paragraph: + Text(document.isMarkdown ? LicenseText.inline(block.text, base: source.documentBaseURL) : AttributedString(block.text)) + .font(.callout) + .foregroundStyle(Theme.fg) + .fixedSize(horizontal: false, vertical: true) + } + } +} diff --git a/appleApp/FTW/About/RemovePhoneView.swift b/appleApp/FTW/About/RemovePhoneView.swift new file mode 100644 index 0000000..2b95d95 --- /dev/null +++ b/appleApp/FTW/About/RemovePhoneView.swift @@ -0,0 +1,67 @@ +import FTWKit +import SwiftUI + +/// Removing this phone: what the app deletes here, and how the owner takes +/// the phone off the box, since no op lets a phone do that itself. +struct RemovePhoneView: View { + let app: AppModel + /// Closes Source & licenses, which has nothing left to show after. + let close: () -> Void + @State private var confirming = false + + var body: some View { + List { + Section { + Text(AboutWords.text("This deletes what FTW keeps on this phone: the key to your home, the pairing, the saved readings and what the widgets show. Your box and everything on it stay as they are.")) + Text(AboutWords.text("FTW has no account with Sourceful. Your box keeps a row for this phone until you remove it there.")) + .foregroundStyle(Theme.fgDim) + } + .font(.callout) + + if let id = app.vault.deviceIDOnBox { + Section { + LabeledContent(AboutWords.text("Your box calls this phone")) { + Text(PhoneRemoval.nameOnBox(id)) + .font(.body.monospaced()) + .foregroundStyle(Theme.fg) + .textSelection(.enabled) + } + ForEach(Array(PhoneRemoval.boxSteps(id).enumerated()), id: \.offset) { index, step in + Label { + Text(step) + } icon: { + Image(systemName: "\(index + 1).circle") + .foregroundStyle(Theme.accent) + } + } + } header: { + Text(AboutWords.text("On your box")) + } footer: { + Text(AboutWords.text("Once it is gone there, the box no longer lets this phone in. Do it before or after the step below.")) + } + } + + Section { + Button(AboutWords.text("Remove from this phone"), role: .destructive) { confirming = true } + // On the button, so the question points at what asked it. + .confirmationDialog(AboutWords.text("Remove this phone?"), isPresented: $confirming, titleVisibility: .visible) { + Button(AboutWords.text("Remove"), role: .destructive) { + close() + app.leave() + } + Button(AboutWords.text("Cancel"), role: .cancel) {} + } message: { + Text(AboutWords.text("FTW forgets your home on this phone. To come back, scan a new pairing QR from your box.")) + } + } header: { + Text(AboutWords.text("On this phone")) + } footer: { + Text(AboutWords.text("If you made a passkey for FTW, it stays in the Passwords app, where you can delete it. Keep it if another device of yours still uses FTW.")) + } + } + .scrollContentBackground(.hidden) + .background(Theme.surface) + .navigationTitle(AboutWords.text("Remove this phone")) + .navigationBarTitleDisplayMode(.inline) + } +} diff --git a/appleApp/FTW/AppIcon.icon/Assets/bolt.svg b/appleApp/FTW/AppIcon.icon/Assets/bolt.svg new file mode 100644 index 0000000..54e730e --- /dev/null +++ b/appleApp/FTW/AppIcon.icon/Assets/bolt.svg @@ -0,0 +1,4 @@ + + + + diff --git a/appleApp/FTW/AppIcon.icon/icon.json b/appleApp/FTW/AppIcon.icon/icon.json new file mode 100644 index 0000000..d018b86 --- /dev/null +++ b/appleApp/FTW/AppIcon.icon/icon.json @@ -0,0 +1,45 @@ +{ + "fill-specializations" : [ + { + "value" : { + "solid" : "srgb:0.05098,0.05098,0.05098,1.00000" + } + }, + { + "appearance" : "dark", + "value" : { + "solid" : "srgb:0.00000,0.00000,0.00000,1.00000" + } + } + ], + "groups" : [ + { + "layers" : [ + { + "glass" : true, + "image-name" : "bolt.svg", + "name" : "bolt", + "position" : { + "scale" : 1.1, + "translation-in-points" : [ + 0, + 0 + ] + } + } + ], + "shadow" : { + "kind" : "neutral", + "opacity" : 0.5 + }, + "specular" : true, + "translucency" : { + "enabled" : true, + "value" : 0.2 + } + } + ], + "supported-platforms" : { + "squares" : "shared" + } +} diff --git a/appleApp/FTW/Assets.xcassets/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/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/Assets.xcassets/LaunchBackground.colorset/Contents.json b/appleApp/FTW/Assets.xcassets/LaunchBackground.colorset/Contents.json new file mode 100644 index 0000000..4cd9661 --- /dev/null +++ b/appleApp/FTW/Assets.xcassets/LaunchBackground.colorset/Contents.json @@ -0,0 +1,38 @@ +{ + "colors" : [ + { + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "1.000", + "blue" : "0xF2", + "green" : "0xF4", + "red" : "0xF4" + } + }, + "idiom" : "universal" + }, + { + "appearances" : [ + { + "appearance" : "luminosity", + "value" : "dark" + } + ], + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "1.000", + "blue" : "0x0D", + "green" : "0x0D", + "red" : "0x0D" + } + }, + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/appleApp/FTW/Car/CarView.swift b/appleApp/FTW/Car/CarView.swift new file mode 100644 index 0000000..720f85e --- /dev/null +++ b/appleApp/FTW/Car/CarView.swift @@ -0,0 +1,492 @@ +import FTWDemo +import FTWKit +import SwiftUI + +/// The car: how full it is and who says so, the goal and whether it makes +/// it, and Charge now, Pause and Back to the plan. Every tap answers at +/// once (a haptic and "Sent"), then the box's receipt, then the outcome; +/// the screen shows intent at once and a result only once the box has +/// confirmed it. +struct CarView: View { + let app: AppModel + let home: HomeModels + let id: String? + @State private var editingGoal = false + + private var model: LoadpointsModel { home.loadpoints } + + private var lp: Loadpoint? { + id.flatMap { want in model.points.first { $0.id == want } } ?? model.points.first + } + + var body: some View { + List { + if let lp { + StatusSection(lp: lp, model: model, site: home.site) + LevelSection(lp: lp, model: model, canControl: home.site.canConfigure) + GoalSection(lp: lp, model: model, site: home.site, canControl: home.site.canConfigure, editing: $editingGoal) + if lp.pluggedIn, home.site.canConfigure { + ControlSection(lp: lp, model: model) + } + if app.isDemo { DemoSection(lp: lp, model: model) } + } else if model.loaded { + Section { Text(L("This box has no charger.")) } + .listRowBackground(Theme.surfaceRaised) + } else { + PlaceholderSections() + } + } + .listStyle(.insetGrouped) + .scrollContentBackground(.hidden) + .background(Theme.surface) + .animation(Theme.spring, value: model.points) + .navigationTitle(L("Car")) + .navigationBarTitleDisplayMode(.inline) + .refreshable { try? await model.loadChargers() } + .onAppear { + model.activate() + model.watchPlan() + LaunchClock.mark("first content") + } + .onDisappear { + model.unwatchPlan() + model.deactivate() + } + .sheet(isPresented: $editingGoal) { + if let lp { GoalEditor(model: model, lp: lp) } + } + } +} + +// MARK: What it does now + +private struct StatusSection: View { + let lp: Loadpoint + let model: LoadpointsModel + let site: SiteModel + + var body: some View { + Section { + VStack(alignment: .leading, spacing: 4) { + Text(EVText.status(lp, zone: model.zone)) + .font(.title3.weight(.semibold)) + .foregroundStyle(Theme.fg) + .contentTransition(.numericText()) + .fixedSize(horizontal: false, vertical: true) + if let session = EVText.session(lp) { + Hint(session).contentTransition(.numericText()) + } + if let note { + Hint(note.text, tone: note.warn ? Theme.generation : Theme.fgMuted) + } + } + .padding(.vertical, 4) + .accessibilityElement(children: .combine) + } + .listRowBackground(Theme.surfaceRaised) + } + + /// When the box said it: plainly when out of date, quietly when it is + /// only a while ago. The chargers are not read on a timer. + private var note: (text: String, warn: Bool)? { + guard let at = model.readAtMs else { return model.stale ? (L("Waiting for your box."), true) : nil } + if model.stale { return (model.error ?? AppWords.format("Last known %@.", AppWords.clock(at, zone: model.zone)), true) } + if site.nowMs - at > 60_000 { return (AppWords.format("Read at %@", AppWords.clock(at, zone: model.zone)), false) } + return nil + } +} + +// MARK: How full is it? + +private struct LevelSection: View { + let lp: Loadpoint + let model: LoadpointsModel + let canControl: Bool + @State private var correcting = false + + var body: some View { + let level = model.level(lp) + let assumed = level?.origin == .assumed || level?.origin == .unconfirmed + Section { + if let level { + VStack(alignment: .leading, spacing: 6) { + ViewThatFits(in: .horizontal) { + HStack(alignment: .firstTextBaseline, spacing: 10) { figure(level, assumed: assumed) } + VStack(alignment: .leading, spacing: 6) { figure(level, assumed: assumed) } + } + // The badge says FTW assumes; the line says what to do. + Text(level.origin == .assumed && canControl ? L("Nobody has told FTW the car's level yet. Choose it below.") : EVText.origin(level, zone: model.zone)) + .font(.subheadline) + .foregroundStyle(assumed ? Theme.fg : Theme.fgDim) + .fixedSize(horizontal: false, vertical: true) + } + .padding(.vertical, 4) + .accessibilityElement(children: .combine) + if canControl { + if level.origin == .car, !correcting { + Button(L("Correct the level")) { + withAnimation(Theme.spring) { correcting = true } + } + } else { + LevelChips(current: model.chosenLevel(lp) ?? level.pct) { pct in + Task { await model.setLevel(lp, pct: pct) } + } + } + } + } else if !lp.pluggedIn { + Text(L("Plug in the car to tell FTW its level.")) + .font(.subheadline) + .foregroundStyle(Theme.fg) + } else { + Text(L("Level unknown")).font(.headline) + } + if let outcome = model.outcome(level: lp) { + ReceiptView(outcome: outcome) + } + } header: { + Text(L("How full is the car?")) + } footer: { + if level?.origin == .car, !correcting, canControl { + Text(L("Only correct it if the car shows something else.")) + } + } + .listRowBackground(Theme.surfaceRaised) + .sensoryFeedback(.selection, trigger: model.chosenLevel(lp)) + .sensoryFeedback(trigger: model.outcome(level: lp)?.tone) { _, tone in Self.feedback(tone) } + } + + @ViewBuilder private func figure(_ level: EVText.Level, assumed: Bool) -> some View { + Text(AppWords.percent(level.pct)) + .font(Theme.percent(.largeTitle, weight: .bold)) + .foregroundStyle(assumed ? Theme.fgMuted : Theme.fg) + .contentTransition(.numericText(value: level.pct)) + .animation(Theme.spring, value: level.pct) + if assumed { + Text(L("FTW assumes")) + .font(.caption.weight(.semibold)) + .foregroundStyle(Theme.onAccent) + .padding(.horizontal, 8) + .padding(.vertical, 3) + .background(Theme.generation, in: Capsule()) + .fixedSize() + } + } + + static func feedback(_ tone: EVText.Outcome.Tone?) -> SensoryFeedback? { + switch tone { + case .done?: return .success + case .warning?: return .warning + case .failed?: return .error + default: return nil + } + } +} + +/// 20, 40, 60, 80 and a step of five either way. Each tap goes to the box +/// at once; there is no Save. +struct LevelChips: View { + let current: Double + let choose: (Double) -> Void + @Environment(\.dynamicTypeSize) private var textSize + + var body: some View { + VStack(spacing: 8) { + // Four across, two at the accessibility sizes: a chip never wraps. + LazyVGrid(columns: Array(repeating: GridItem(.flexible(), spacing: 8), count: textSize.isAccessibilitySize ? 2 : 4), spacing: 8) { + ForEach(EVText.levelPresets, id: \.self) { pct in + chip(AppWords.percent(pct), selected: current.rounded() == pct, label: AppWords.percent(pct)) { choose(pct) } + } + } + HStack(spacing: 8) { + let down = EVText.stepped(current, up: false) + let up = EVText.stepped(current, up: true) + chip("−5", selected: false, label: AppWords.format("Set %@", AppWords.percent(down))) { choose(down) } + chip("+5", selected: false, label: AppWords.format("Set %@", AppWords.percent(up))) { choose(up) } + } + } + .padding(.vertical, 4) + .animation(Theme.spring, value: current) + } + + @ViewBuilder private func chip(_ text: String, selected: Bool, label: String, action: @escaping () -> Void) -> some View { + let title = Text(text) + .font(Theme.percent(.body)) + .lineLimit(1) + .minimumScaleFactor(0.6) + .padding(.vertical, 6) + .frame(maxWidth: .infinity) + Group { + if selected { + Button(action: action) { title.foregroundStyle(Theme.onAccent) } + .buttonStyle(.borderedProminent) + .tint(Theme.accent) + } else { + Button(action: action) { title.foregroundStyle(Theme.fg) } + .buttonStyle(.bordered) + .tint(Theme.fgMuted) + } + } + .buttonBorderShape(.roundedRectangle(radius: Theme.radiusSmall)) + .accessibilityLabel(label) + .accessibilityAddTraits(selected ? .isSelected : []) + } +} + +// MARK: The goal + +private struct GoalSection: View { + let lp: Loadpoint + let model: LoadpointsModel + let site: SiteModel + let canControl: Bool + @Binding var editing: Bool + + var body: some View { + Section { + if model.fromCache { + // The cache keeps no goal; say nothing rather than "none". + Text(EVText.suggestedGoalText()) + .font(.headline) + .redacted(reason: .placeholder) + } else if let s = lp.schedule { + VStack(alignment: .leading, spacing: 4) { + Text(EVText.goal(s, at: Date(timeIntervalSince1970: site.nowMs / 1000), zone: model.zone)) + .font(.headline) + .foregroundStyle(Theme.fg) + .fixedSize(horizontal: false, vertical: true) + if let next = EVText.nextGoal(lp, nowMs: site.nowMs, zone: model.zone) { + Hint(next) + } + } + .padding(.vertical, 2) + if lp.pluggedIn, let r = model.readiness(lp) { + Readiness(readiness: r, text: EVText.readiness(r, level: model.level(lp), nowMs: site.nowMs, zone: model.zone)) + } + if lp.pluggedIn, let plan = EVText.plan(lp, windows: model.windows[lp.id] ?? lp.planWindows, nowMs: site.nowMs, zone: model.zone) { + Hint(plan) + } + if canControl { + Button(L("Change")) { editing = true } + } + } else { + Text(L("No goal yet. Without one, FTW only charges from spare solar.")) + .font(.subheadline) + .foregroundStyle(Theme.fg) + .fixedSize(horizontal: false, vertical: true) + if canControl { + Button { + let g = EVText.suggestedGoal + Task { await model.saveGoal(lp, socPct: g.socPct, clock: g.clock, recurring: true, days: g.days, zone: model.zone) } + } label: { + Text(AppWords.format("Use %@", EVText.suggestedGoalText())) + .frame(maxWidth: .infinity) + .foregroundStyle(Theme.onAccent) + } + .buttonStyle(.borderedProminent) + .controlSize(.large) + Button(L("Change")) { editing = true } + } + } + if let save = model.goalSave[lp.id], !editing { + Hint(LoadpointsModel.goalText(save), tone: { if case .failed = save { return Theme.importing }; return Theme.fgDim }()) + } + } header: { + Text(L("Goal")) + } + .listRowBackground(Theme.surfaceRaised) + .sensoryFeedback(trigger: model.goalSave[lp.id]) { _, save in + switch save { + case .saved?, .removed?: return .success + case .failed?: return .error + default: return nil + } + } + } +} + +/// Whether the car makes its goal, in the plan's own numbers. +struct Readiness: View { + let readiness: EVText.Readiness + let text: String + + var body: some View { + Label { + Text(text) + .font(.subheadline.weight(.medium)) + .foregroundStyle(Theme.fg) + .fixedSize(horizontal: false, vertical: true) + } icon: { + Image(systemName: short ? "exclamationmark.triangle.fill" : "checkmark.circle.fill") + .foregroundStyle(short ? Theme.generation : Theme.exporting) + .contentTransition(.symbolEffect(.replace)) + } + .accessibilityElement(children: .combine) + } + + private var short: Bool { + if case .short = readiness { return true } + return false + } +} + +// MARK: Charge now, Pause, Back to the plan + +private struct ControlSection: View { + let lp: Loadpoint + let model: LoadpointsModel + @State private var taps = 0 + + var body: some View { + let paused = EVText.isPaused(lp) + let holding = lp.manualActive && !paused + let busy = model.holdSending(lp) + Section { + VStack(spacing: 10) { + if holding || paused { + action(L("Back to the plan"), symbol: "calendar.badge.clock", prominent: true, busy: busy) { await model.backToPlan(lp) } + } + if !holding { + action(L("Charge now"), symbol: "bolt.fill", prominent: !paused, busy: busy) { await model.chargeNow(lp) } + } + if !paused { + action(L("Pause"), symbol: "pause.fill", prominent: false, busy: busy) { await model.pause(lp) } + } + } + .padding(.vertical, 6) + if let outcome = model.outcome(hold: lp) { + ReceiptView(outcome: outcome) + } + } footer: { + Text(L("Charge now runs until you go back to the plan or unplug.")) + } + .listRowBackground(Theme.surfaceRaised) + .sensoryFeedback(.impact(weight: .medium), trigger: taps) + .sensoryFeedback(trigger: model.outcome(hold: lp)?.tone) { _, tone in LevelSection.feedback(tone) } + } + + @ViewBuilder private func action(_ title: String, symbol: String, prominent: Bool, busy: Bool, _ run: @escaping () async -> Void) -> some View { + let label = Label(title, systemImage: symbol) + .font(.body.weight(.semibold)) + .frame(maxWidth: .infinity) + Group { + if prominent { + Button { + taps += 1 + Task { await run() } + } label: { label.foregroundStyle(Theme.onAccent) } + .buttonStyle(.borderedProminent) + } else { + Button { + taps += 1 + Task { await run() } + } label: { label.foregroundStyle(Theme.fg) } + .buttonStyle(.bordered) + .tint(Theme.fgMuted) + } + } + .controlSize(.large) + .disabled(busy) + } +} + +// MARK: The demo's own cable + +private struct DemoSection: View { + let lp: Loadpoint + let model: LoadpointsModel + + var body: some View { + Section { + Button(L(lp.pluggedIn ? "Unplug the car" : "Plug in the car")) { + SimulatedBox.current?.setCarPluggedIn(!lp.pluggedIn) + model.reread() + } + } header: { + Text(L("Demo")) + } footer: { + Text(L("The demo plugs in a car FTW cannot read, so FTW assumes 20 %.")) + } + .listRowBackground(Theme.surfaceRaised) + } +} + +/// Before the first answer and with nothing cached: the shape of the +/// screen, not a spinner. +private struct PlaceholderSections: View { + var body: some View { + Group { + Section { Text("Plugged in · charges at 01:00").font(.title3.weight(.semibold)) } + Section { Text("20 %").font(Theme.percent(.largeTitle, weight: .bold)) } + Section { Text("80 % by 07:00 on weekdays").font(.headline) } + } + .listRowBackground(Theme.surfaceRaised) + .redacted(reason: .placeholder) + } +} + +/// The receipt under a control: sent, received, done, and one sentence. +struct ReceiptView: View { + let outcome: EVText.Outcome + + var body: some View { + VStack(alignment: .leading, spacing: 6) { + ViewThatFits(in: .horizontal) { + HStack(spacing: 12) { marks } + VStack(alignment: .leading, spacing: 4) { marks } + } + Text(outcome.text) + .font(.footnote.weight(.medium)) + .foregroundStyle(color) + .contentTransition(.opacity) + .fixedSize(horizontal: false, vertical: true) + } + .padding(.vertical, 4) + .animation(Theme.spring, value: outcome) + .accessibilityElement(children: .combine) + } + + @ViewBuilder private var marks: some View { + mark(L("Sent"), outcome.sent) + mark(L("Received"), outcome.received) + mark(L("Done"), outcome.done) + } + + private func mark(_ label: String, _ state: EVText.Outcome.Mark) -> some View { + HStack(spacing: 4) { + Image(systemName: symbol(state)) + .foregroundStyle(tint(state)) + .contentTransition(.symbolEffect(.replace)) + .symbolEffect(.pulse, options: .repeating, isActive: state == .active) + Text(label) + .font(.caption.weight(.semibold)) + .foregroundStyle(state == .waiting ? Theme.fgMuted : Theme.fg) + } + } + + private func symbol(_ state: EVText.Outcome.Mark) -> String { + switch state { + case .done: return "checkmark.circle.fill" + case .active: return "circle.dotted.circle" + case .waiting: return "circle" + case .failed: return "xmark.circle.fill" + } + } + + private func tint(_ state: EVText.Outcome.Mark) -> Color { + switch state { + case .done: return Theme.exporting + case .active: return Theme.accent + case .waiting: return Theme.fgMuted + case .failed: return Theme.importing + } + } + + private var color: Color { + switch outcome.tone { + case .working: return Theme.fgDim + case .done: return Theme.exporting + case .warning: return Theme.generation + case .failed: return Theme.importing + } + } +} diff --git a/appleApp/FTW/Car/GoalEditor.swift b/appleApp/FTW/Car/GoalEditor.swift new file mode 100644 index 0000000..dc63242 --- /dev/null +++ b/appleApp/FTW/Car/GoalEditor.swift @@ -0,0 +1,164 @@ +import FTWKit +import SwiftUI + +/// The goal: how full, by when, on which days. One Save, one PUT. The time +/// is set and shown on the household clock, the one the box keeps. +struct GoalEditor: View { + let model: LoadpointsModel + let lp: Loadpoint + @Environment(\.dismiss) private var dismiss + @State private var socPct: Double + @State private var time: Date + @State private var days: Days + @State private var saving = false + + enum Days: Hashable { + case weekdays, everyDay, weekends, once + case custom(Int) + + init(recurring: Bool, mask: Int) { + guard recurring else { self = .once; return } + switch mask & 0x7f { + case 0, 0x7f: self = .everyDay + case 0b0011111: self = .weekdays + case 0b1100000: self = .weekends + default: self = .custom(mask & 0x7f) + } + } + + var recurring: Bool { self != .once } + + var mask: Int { + switch self { + case .weekdays: return 0b0011111 + case .everyDay, .once: return 0x7f + case .weekends: return 0b1100000 + case .custom(let m): return m + } + } + + var label: String { + switch self { + case .weekdays: return L("Weekdays") + case .everyDay: return L("Every day") + case .weekends: return L("Weekends") + case .once: return L("Once") + case .custom(let m): + let days = EVText.days(m) + return days.prefix(1).uppercased() + days.dropFirst() + } + } + } + + init(model: LoadpointsModel, lp: Loadpoint) { + self.model = model + self.lp = lp + let zone = model.zone + let now = Date() + let s = lp.schedule + _socPct = State(initialValue: s.map { $0.finishAtVehicleLimit ? 100 : $0.socPct } ?? EVText.suggestedGoal.socPct) + let clock = s.map { EVText.goalClock($0, at: now, zone: zone) } ?? EVText.suggestedGoal.clock + _time = State(initialValue: Self.date(clock: clock, zone: zone)) + _days = State(initialValue: s.map { Days(recurring: $0.recurring, mask: $0.days) } ?? .weekdays) + } + + static func calendar(_ zone: TimeZone) -> Calendar { + var cal = Calendar(identifier: .gregorian) + cal.timeZone = zone + return cal + } + + /// Today at that clock in the household zone, for the time wheel. + static func date(clock: Int, zone: TimeZone) -> Date { + let cal = calendar(zone) + return cal.date(bySettingHour: clock / 60, minute: clock % 60, second: 0, of: Date()) ?? Date() + } + + private var clock: Int { + let c = Self.calendar(model.zone).dateComponents([.hour, .minute], from: time) + return (c.hour ?? 0) * 60 + (c.minute ?? 0) + } + + private var choices: [Days] { + var out: [Days] = [.weekdays, .everyDay, .weekends, .once] + if case .custom = days { out.insert(days, at: 0) } + return out + } + + private var failure: String? { + if case .failed(let help)? = model.goalSave[lp.id], !saving { return help } + return nil + } + + var body: some View { + NavigationStack { + Form { + Section(L("Charge to")) { + Stepper(value: $socPct, in: 10...100, step: 5) { + Text(AppWords.percent(socPct)).font(Theme.percent(.title2)) + } + } + Section(L("Ready by")) { + DatePicker(L("Ready by"), selection: $time, displayedComponents: .hourAndMinute) + .datePickerStyle(.wheel) + .labelsHidden() + .environment(\.timeZone, model.zone) + .frame(maxWidth: .infinity) + } + Section(L("Repeat")) { + Picker(L("Repeat"), selection: $days) { + ForEach(choices, id: \.self) { Text($0.label).tag($0) } + } + .pickerStyle(.inline) + .labelsHidden() + } + Section { + Text(preview).font(.subheadline).foregroundStyle(Theme.fgDim) + if let failure { + Text(failure).font(.subheadline).foregroundStyle(Theme.importing) + } + } + if lp.schedule != nil { + Section { + Button(L("Remove goal"), role: .destructive) { + Task { + saving = true + await model.removeGoal(lp) + saving = false + if case .removed? = model.goalSave[lp.id] { dismiss() } + } + } + .disabled(saving) + } + } + } + .navigationTitle(L("Goal")) + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .cancellationAction) { + Button(L("Cancel")) { dismiss() } + } + ToolbarItem(placement: .confirmationAction) { + if saving { + ProgressView() + } else { + Button(L("Save")) { + Task { + saving = true + await model.saveGoal(lp, socPct: socPct, clock: clock, recurring: days.recurring, days: days.mask, zone: model.zone) + saving = false + if case .saved? = model.goalSave[lp.id] { dismiss() } + } + } + } + } + } + } + } + + /// The goal as it will read on the car view. + private var preview: String { + let s = Loadpoint.Schedule(socPct: socPct, timeOfDayMinUTC: 0, clockMin: clock, surplusUnlockPct: 0, recurring: days.recurring, days: days == .everyDay ? 0 : days.mask, finishAtVehicleLimit: false) + return EVText.goal(s, at: Date(), zone: model.zone) + } +} diff --git a/appleApp/FTW/FTWApp.swift b/appleApp/FTW/FTWApp.swift new file mode 100644 index 0000000..e0591e1 --- /dev/null +++ b/appleApp/FTW/FTWApp.swift @@ -0,0 +1,125 @@ +import BackgroundTasks +import FTWDemo +import FTWKit +import os +import SwiftUI +import WidgetKit + +@main +struct FTWApp: App { + @State private var app: AppModel + @Environment(\.scenePhase) private var scenePhase + private let network: NetworkWatch + + init() { + let store = KeychainStore() + let files = SealedFiles(directory: SealedFiles.defaultDirectory(), store: store) + let model = AppModel( + store: store, + files: files, + // What the widgets read. Nil only in a build without the App + // Group entitlement, and then the app works without widgets. + shared: AppGroupFiles.shared(), + sharedWritten: { WidgetCenter.shared.reloadAllTimelines() }, + passkeys: Passkeys(), + build: AppInfo.build, + ua: AppInfo.userAgent, + // App Review and anyone without a box can try a simulated home. + demo: SimulatedBox.loopback + ) + // A paired phone paints its cached home before anything else runs. + model.launch() + #if DEBUG + // For screenshots: `-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 { + RootView(app: app) + // A pairing QR scanned with the iOS camera arrives as a + // universal link to https://app.ftw.energy/p#…, and takes the + // same path as any other pairing link. + .onOpenURL { app.offer($0.absoluteString) } + .onContinueUserActivity(NSUserActivityTypeBrowsingWeb) { activity in + if let url = activity.webpageURL { app.offer(url.absoluteString) } + } + .onChange(of: scenePhase, initial: true) { _, phase in + // Inactive is a glance at Control Center or the app + // switcher: slow the stream, keep the session. Only the + // background closes it. + app.setVisible(phase == .active) + PricesInBackground.inBackground = phase == .background + if phase == .background { + app.suspend() + Task { await PricesInBackground.schedule(app) } + } + } + } + .backgroundTask(.appRefresh(PricesInBackground.id)) { [app] in + await PricesInBackground.run(app) + } + } +} + +/// Tomorrow's prices for the widgets without opening the app. iOS wakes the +/// app shortly after 13:05 if it allows it at all; the app opens a session, +/// asks for prices, leaves them in the App Group and closes again. No silent +/// push and no server: when iOS does not wake the app, the widgets wait for +/// the next time it is opened. +@MainActor +enum PricesInBackground { + /// Listed under BGTaskSchedulerPermittedIdentifiers in FTW-Info.plist. + static let id = "energy.ftw.app.prices" + static let log = Logger(subsystem: "energy.ftw.app", category: "widgets") + /// Set from the scene phase. A cold launch for this task has no scene, + /// so it starts in the background. + static var inBackground = true + + static func schedule(_ app: AppModel) async { + guard let site = app.site, site.siteId != nil, !app.isDemo else { return } + let pricesEndMs = await site.sharedPricesEndMs() + let nowMs = Date.now.timeIntervalSince1970 * 1000 + let at = Date(timeIntervalSince1970: PriceRefresh.next(afterMs: nowMs, pricesEndMs: pricesEndMs) / 1000) + let request = BGAppRefreshTaskRequest(identifier: id) + request.earliestBeginDate = at + do { + try BGTaskScheduler.shared.submit(request) + log.info("background prices asked for after \(at.formatted(date: .abbreviated, time: .shortened), privacy: .public)") + } catch { + // The simulator, Background App Refresh turned off, or Low Power + // Mode. The widgets then wait for the app to be opened. + log.notice("background prices not scheduled: \(error.localizedDescription, privacy: .public)") + } + } + + static func run(_ app: AppModel) async { + guard let site = app.site, site.siteId != nil, !app.isDemo else { return } + log.info("background prices: woken") + // A new carrier if the last one was closed, or a fresh stream. + await app.refresh() + let prices = await site.refreshSharedPrices() + log.info("background prices: \(prices == nil ? "nothing written" : "\(prices!.slots.count) slots written", privacy: .public)") + // Someone may have opened the app meanwhile; their session stays. + if inBackground { app.suspend() } + await schedule(app) + } +} + +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 { "FTW iOS \(version)" } +} diff --git a/appleApp/FTW/Platform/KeychainStore.swift b/appleApp/FTW/Platform/KeychainStore.swift new file mode 100644 index 0000000..16f5611 --- /dev/null +++ b/appleApp/FTW/Platform/KeychainStore.swift @@ -0,0 +1,49 @@ +import Foundation +import FTWKit +import Security + +/// The secure store: generic passwords, readable after first unlock, never +/// synced and never migrated to another device. Cold start reads it without +/// Face ID, which is what lets the cache paint first. +/// +/// No access group is named, so every item lives in the app's own default +/// group. The widget extension has a different one and can read none of it: +/// widgets get the App Group cache and no key. +@MainActor +final class KeychainStore: SecureStore { + private let service = "energy.ftw.app" + + func get(_ key: String) -> Bytes? { + var query = base(key) + query[kSecReturnData as String] = true + query[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.byteArray + } + + func put(_ key: String, _ value: Bytes) { + let query = base(key) + let update: [String: Any] = [kSecValueData as String: Data(value)] + let status = SecItemUpdate(query as CFDictionary, update as CFDictionary) + if status == errSecItemNotFound { + var item = query + item[kSecValueData as String] = Data(value) + item[kSecAttrAccessible as String] = kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly + SecItemAdd(item as CFDictionary, nil) + } + } + + func remove(_ key: String) { + SecItemDelete(base(key) as CFDictionary) + } + + private func base(_ key: String) -> [String: Any] { + [ + kSecClass as String: kSecClassGenericPassword, + kSecAttrService as String: service, + kSecAttrAccount as String: key, + ] + } +} 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..beb876c --- /dev/null +++ b/appleApp/FTW/Platform/Passkeys.swift @@ -0,0 +1,136 @@ +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 { + let scenes = UIApplication.shared.connectedScenes.compactMap { $0 as? UIWindowScene } + let windows = scenes.flatMap(\.windows) + if let window = windows.first(where: \.isKeyWindow) ?? windows.first { return window } + guard let scene = scenes.first else { + // A ceremony starts from a button on screen, so a scene exists. + preconditionFailure("passkey ceremony with no window scene") + } + return ASPresentationAnchor(windowScene: scene) + } +} diff --git a/appleApp/FTW/Platform/QRScanner.swift b/appleApp/FTW/Platform/QRScanner.swift new file mode 100644 index 0000000..8074d26 --- /dev/null +++ b/appleApp/FTW/Platform/QRScanner.swift @@ -0,0 +1,114 @@ +import AVFoundation +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? + + static func looksLikePairing(_ text: String) -> Bool { + text.contains("ftw.energy") || text.contains("/p#") + } + + 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 + } + } + } +} + +/// 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() } } +} + +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, QRScanner.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) } + } + } +} diff --git a/appleApp/FTW/PrivacyInfo.xcprivacy b/appleApp/FTW/PrivacyInfo.xcprivacy new file mode 100644 index 0000000..dba570d --- /dev/null +++ b/appleApp/FTW/PrivacyInfo.xcprivacy @@ -0,0 +1,31 @@ + + + + + NSPrivacyTracking + + NSPrivacyTrackingDomains + + NSPrivacyCollectedDataTypes + + NSPrivacyAccessedAPITypes + + + NSPrivacyAccessedAPIType + NSPrivacyAccessedAPICategoryFileTimestamp + NSPrivacyAccessedAPITypeReasons + + C617.1 + + + + NSPrivacyAccessedAPIType + NSPrivacyAccessedAPICategoryUserDefaults + NSPrivacyAccessedAPITypeReasons + + CA92.1 + + + + + diff --git a/appleApp/FTW/UI/FlowDiagram.swift b/appleApp/FTW/UI/FlowDiagram.swift new file mode 100644 index 0000000..cd9e8a6 --- /dev/null +++ b/appleApp/FTW/UI/FlowDiagram.swift @@ -0,0 +1,180 @@ +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, Equatable { + let readings: Flow.Readings + let moving: Bool + + 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) + + ForEach(readings.nodes) { node in + Bubble(node: node) + .position(layout.points[node.id] ?? layout.hub) + } + } + } + .frame(height: Self.height) + .frame(maxWidth: 520) + .frame(maxWidth: .infinity) + // A drawing with fixed places: past this size its labels would + // collide, and every figure in it is also said in words elsewhere. + .dynamicTypeSize(...DynamicTypeSize.xLarge) + } + + /// Where each node sits. Several nodes in one corner stack toward the + /// 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.. log show --last 2m \ +/// --predicate 'subsystem == "energy.ftw.app" AND category == "launch"' +@MainActor +enum LaunchClock { + static let log = Logger(subsystem: "energy.ftw.app", category: "launch") + static let signposter = OSSignposter(logger: log) + private static var seen: Set = [] + + /// Once per event: the first frame, the first content, the stream. + static func mark(_ event: StaticString) { + let name = "\(event)" + guard seen.insert(name).inserted else { return } + signposter.emitEvent(event) + if let ms = sinceProcessStartMs() { + log.notice("\(name, privacy: .public) \(Int(ms.rounded()), privacy: .public) ms after process start") + } + } + + /// The kernel's record of when this process started. + static func sinceProcessStartMs() -> Double? { + var info = kinfo_proc() + var size = MemoryLayout.stride + var mib: [Int32] = [CTL_KERN, KERN_PROC, KERN_PROC_PID, getpid()] + guard sysctl(&mib, u_int(mib.count), &info, &size, nil, 0) == 0 else { return nil } + let start = info.kp_proc.p_starttime + var now = timeval() + gettimeofday(&now, nil) + return Double(now.tv_sec - start.tv_sec) * 1000 + Double(now.tv_usec - start.tv_usec) / 1000 + } +} diff --git a/appleApp/FTW/UI/NowView.swift b/appleApp/FTW/UI/NowView.swift new file mode 100644 index 0000000..2873725 --- /dev/null +++ b/appleApp/FTW/UI/NowView.swift @@ -0,0 +1,287 @@ +import FTWKit +import SwiftUI + +/// Now, the first screen, kept to what is looked at every day: whether FTW +/// is in control, the house, the car and the price. +/// +/// Lane 0 drives it. Nothing here asks the box on a timer: the readings +/// arrive on the stream, the price when the day or the published window +/// turns, and the chargers when the screen appears. Each part is its own +/// small view reading only what it shows, so a frame redraws the numbers +/// that moved and nothing else. +struct NowView: View { + let app: AppModel + let home: HomeModels + let zoom: Namespace.ID + + var body: some View { + NowBody(app: app, home: home, zoom: zoom) + .onAppear { home.prices.activate() } + .onDisappear { home.prices.deactivate() } + } +} + +private struct NowBody: View { + let app: AppModel + let home: HomeModels + let zoom: Namespace.ID + + private var site: SiteModel { home.site } + + var body: some View { + switch site.session.phase { + case .booting: + Title(L("Your box is starting")) + Text(L("This can take a few minutes after an update. Nothing is wrong. Your home appears here when it is ready.")) + if let boot = site.session.boot { + Hint("\(boot.pct)%") + } + case .terminated: + Title(L("Access ended")) + Text(site.session.terminated == .revoked ? L("Your access to this home was withdrawn by its owner.") : L("This session ended.")) + if site.session.terminated == .revoked { + Button(L("Your box won't let this phone in?")) { app.recovering = true }.buttonStyle(.quiet) + } + default: + if !site.session.fields.isEmpty { + house + } else if app.isDemo { + // The demo's first reading is a moment away: the shape of the + // screen, not a sentence about waiting. + InControlRow(answer: InControl.Answer(tone: .neutral, title: L("Starting the demo"), detail: L("Live demo · simulated home"))) + .redacted(reason: .placeholder) + } else { + nothingYet + } + } + AboutLink(app: app) + } + + /// 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(L("Nothing from your box yet")) + Text(app.connectHelp ?? L("This phone is paired to it and keeps trying on its own. Your home appears here as soon as the box answers.")) + if app.connectHelp != nil { + Button(L("Get this phone back in")) { app.recovering = true }.buttonStyle(.primary) + } else if !app.isDemo { + Button(L("Your box won't let this phone in?")) { app.recovering = true }.buttonStyle(.quiet) + } + } + + @ViewBuilder private var house: some View { + if let help = app.connectHelp { + Card { + Text(help) + Button(L("Get this phone back in")) { app.recovering = true }.buttonStyle(.quiet) + } + } + if site.session.needsUpdate { + Card { Text(L("This app is older than your box. Some things are hidden until it updates.")) } + } + InControlSection(site: site) + FlowSection(site: site, loadpoints: home.loadpoints) + if site.heardFromBox || home.loadpoints.fromCache { + CarSection(loadpoints: home.loadpoints, zoom: zoom) + } + PriceSection(site: site, prices: home.prices) + } +} + +// MARK: Is FTW in control? + +private struct InControlSection: View { + let site: SiteModel + + var body: some View { + InControlRow(answer: InControl.answer( + streaming: site.isLive, + lastKnownMs: site.cachedAtMs ?? site.lastFrameAtMs, + dispatchBlockedBy: site.session.dispatchBlockedBy, + sources: site.session.sources + )) + .equatable() + .onAppear { LaunchClock.mark("first content") } + } +} + +/// "Is FTW in control?" in one calm line. Green only while frames arrive +/// and the box says nothing blocks it; a cache is grey and dated. +struct InControlRow: View, Equatable { + let answer: InControl.Answer + + var body: some View { + HStack(alignment: .firstTextBaseline, spacing: 10) { + Image(systemName: symbol) + .font(.body.weight(.semibold)) + .foregroundStyle(color) + .contentTransition(.symbolEffect(.replace)) + .symbolEffect(.bounce, value: answer.tone) + .accessibilityHidden(true) + VStack(alignment: .leading, spacing: 2) { + Kicker(L("Is FTW in control?")) + Text(answer.title) + .font(.headline) + .foregroundStyle(Theme.fg) + .fixedSize(horizontal: false, vertical: true) + if let detail = answer.detail { + Hint(detail) + } + } + Spacer(minLength: 0) + } + .padding(.vertical, 12) + .padding(.horizontal, 16) + .frame(maxWidth: .infinity, alignment: .leading) + .background(Theme.surfaceRaised, in: RoundedRectangle(cornerRadius: Theme.radius)) + .overlay(RoundedRectangle(cornerRadius: Theme.radius).strokeBorder(Theme.line, lineWidth: 1)) + .overlay(alignment: .leading) { + UnevenRoundedRectangle(topLeadingRadius: Theme.radius, bottomLeadingRadius: Theme.radius) + .fill(color) + .frame(width: 4) + } + .animation(Theme.spring, value: answer) + .accessibilityElement(children: .combine) + } + + private var symbol: String { + switch answer.tone { + case .ok: return "checkmark.circle.fill" + case .warning: return "exclamationmark.triangle.fill" + case .neutral: return "circle.dotted" + case .stale: return "clock" + } + } + + private var color: Color { + switch answer.tone { + case .ok: return Theme.exporting + case .warning: return Theme.generation + case .neutral, .stale: return Theme.fgMuted + } + } +} + +// MARK: The house + +private struct FlowSection: View { + let site: SiteModel + let loadpoints: LoadpointsModel + + var body: some View { + // The stream carries the car's watts; a box that leaves them out + // gets the charger's last answer. + let evW = loadpoints.stale ? 0 : Flow.loadpointChargeW(loadpoints.points) + // Motion claims power is flowing right now, so a cached view holds still. + FlowDiagram(readings: Flow.readings(fields: Flow.withLoadpointEV(site.session.fields, evW: evW)), moving: site.isLive) + .equatable() + } +} + +// MARK: The car + +private struct CarSection: View { + let loadpoints: LoadpointsModel + let zoom: Namespace.ID + + var body: some View { + ForEach(loadpoints.points) { lp in + NavigationLink(value: HomeRoute.car(lp.id)) { + CarRow(lp: lp, loadpoints: loadpoints) + } + .buttonStyle(.plain) + .matchedTransitionSource(id: lp.id, in: zoom) + } + } +} + +/// The car on Now: one row that opens the car view. +struct CarRow: View { + static let firstID = "car" + + let lp: Loadpoint + let loadpoints: LoadpointsModel + + var body: some View { + HStack(spacing: 12) { + Image(systemName: lp.pluggedIn ? "bolt.car.fill" : "car.side") + .font(.title3) + .foregroundStyle(Theme.mobility) + .symbolEffect(.pulse, options: .repeating, isActive: lp.powerW >= 100 && !loadpoints.stale) + .contentTransition(.symbolEffect(.replace)) + .frame(width: 32) + .accessibilityHidden(true) + VStack(alignment: .leading, spacing: 2) { + // One text, so it wraps as one line at large sizes. + heading + .foregroundStyle(Theme.fg) + .fixedSize(horizontal: false, vertical: true) + Text(line) + .font(.subheadline) + .foregroundStyle(Theme.fgDim) + .multilineTextAlignment(.leading) + .fixedSize(horizontal: false, vertical: true) + .contentTransition(.numericText()) + } + Spacer(minLength: 4) + Image(systemName: "chevron.right") + .font(.footnote.weight(.semibold)) + .foregroundStyle(Theme.fgMuted) + .accessibilityHidden(true) + } + .padding(.vertical, 12) + .padding(.horizontal, 16) + .frame(maxWidth: .infinity, alignment: .leading) + .background(Theme.surfaceRaised, in: RoundedRectangle(cornerRadius: Theme.radius)) + .overlay(RoundedRectangle(cornerRadius: Theme.radius).strokeBorder(Theme.line, lineWidth: 1)) + .contentShape(RoundedRectangle(cornerRadius: Theme.radius)) + .animation(Theme.spring, value: lp) + .accessibilityElement(children: .combine) + .accessibilityAddTraits(.isButton) + } + + /// "Car 20 % FTW assumes". + private var heading: Text { + let car = Text(L("Car")).font(.headline) + guard let level = loadpoints.level(lp) else { return car } + let pct = Text(AppWords.percent(level.pct)).font(Theme.percent(.headline)).foregroundStyle(level.origin == .assumed ? Theme.fgMuted : Theme.fg) + let origin = Text(EVText.originShort(level, zone: loadpoints.zone)).font(.caption).foregroundStyle(Theme.fgDim) + return Text("\(car) \(pct) \(origin)") + } + + /// What it does now; unplugged, the goal it will meet next time. From + /// the cache or a lost stream, when the box last said so. + private var line: String { + if loadpoints.stale, let at = loadpoints.readAtMs { + return AppWords.format("Last known %@.", AppWords.clock(at, zone: loadpoints.zone)) + } + if !lp.pluggedIn, let s = lp.schedule { + return "\(EVText.status(lp)) · \(EVText.goal(s, at: Date(), zone: loadpoints.zone))" + } + return EVText.status(lp, zone: loadpoints.zone) + } +} + +// MARK: The price + +private struct PriceSection: View { + let site: SiteModel + let prices: PriceModel + + var body: some View { + if let window = prices.prices { + // The chart marks the minute, not the second: it redraws once a + // minute however often the clock ticks. + let minute = (site.nowMs / 60_000).rounded(.down) * 60_000 + Card { + PriceChart(prices: window, nowMs: minute, compact: true) + .equatable() + if prices.hasHole { + Hint(L("Some hours are missing their price.")) + } else if window.stale { + Hint(L("Tomorrow's rates aren't published yet.")) + } + } + } + } +} diff --git a/appleApp/FTW/UI/PairView.swift b/appleApp/FTW/UI/PairView.swift new file mode 100644 index 0000000..b1e1c67 --- /dev/null +++ b/appleApp/FTW/UI/PairView.swift @@ -0,0 +1,191 @@ +import FTWKit +import SwiftUI +#if targetEnvironment(simulator) +import PhotosUI +#endif + +/// Pairing, the first thing anyone sees. Scan, then Face ID. Everything that +/// can fail does so before the passkey prompt. +struct PairView: View { + let app: AppModel + @State private var model: PairModel + #if targetEnvironment(simulator) + @State private var picture: PhotosPickerItem? + #endif + + init(app: AppModel) { + self.app = app + _model = State(initialValue: PairModel(app: app)) + } + + var body: some View { + ScrollView { + VStack(alignment: .leading, spacing: 16) { + content + AboutLink(app: app) + } + .padding(20) + .frame(maxWidth: 520, alignment: .leading) + .frame(maxWidth: .infinity) + } + .background(Theme.surface) + } + + /// 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 .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))). 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))). 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(app.canDemo ? "Connect your own box, or try a live simulated home first." : "Connect your own box.") + } + if let message = model.message { Problem(message) } + + if problem == nil, !canDismiss, !model.canOpen, app.canDemo { + 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 + } + + 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, prices and EV charging. 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, your phone asks for Face ID or Touch ID to protect your FTW key. There is no FTW account or password. If this phone loses its key, a new Settings QR is the way back.") + Button("Scan the pairing QR") { model.stage = .scanning }.buttonStyle(.primary) + #if targetEnvironment(simulator) + // The simulator has no camera. A screenshot of the QR in Photos + // takes the same path as a scan. + PhotosPicker("Choose a picture of the QR", selection: $picture, matching: .images) + .buttonStyle(.quiet) + .onChange(of: picture) { _, item in read(item) } + #endif + DisclosureGroup("Can't see Show pairing code?") { + Hint("Turn on Let the FTW app connect to this box, save, and restart the box first.") + .frame(maxWidth: .infinity, alignment: .leading) + } + .font(.footnote) + } + } +} + +#if targetEnvironment(simulator) +extension PairView { + private func read(_ item: PhotosPickerItem?) { + guard let item else { return } + Task { + let data = try? await item.loadTransferable(type: Data.self) + picture = nil + if let link = data.flatMap(PairingImage.link(in:)) { + await model.pair(link) + } else { + model.noCodeFound() + } + } + } +} +#endif + +struct Title: View { + let text: String + init(_ text: String) { self.text = text } + + 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/PriceChart.swift b/appleApp/FTW/UI/PriceChart.swift new file mode 100644 index 0000000..fc743c8 --- /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, Equatable { + 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(L("Market now")) + Text(L("Electricity price")).font(.headline) + } + HStack(alignment: .firstTextBaseline) { + VStack(alignment: .leading, spacing: 2) { + Text("\(Text(PriceStrip.text(summary.current?.totalMinor, prices.currency)).font(Theme.number(28, weight: .bold)))\(Text(" \(unit.perKwh)").font(.caption))") + Text("\(prices.zone.isEmpty ? "—" : prices.zone) · \(L("incl. fees and VAT"))").font(.caption).foregroundStyle(Theme.fgMuted) + } + Spacer() + VStack(alignment: .trailing, spacing: 2) { + Text(L("Cheapest 2 h")).font(.caption).foregroundStyle(Theme.fgDim) + if let block = summary.cheapest { + Text("\(PriceStrip.text(block.meanMinor, prices.currency)) \(unit.label)").font(Theme.number(15)).foregroundStyle(Theme.exporting) + Text("\(AppWords.clock(block.startMs, zone: .current))–\(AppWords.clock(block.endMs, zone: .current))").font(.caption).foregroundStyle(Theme.fgDim) + } else { + Text(L("No 2 h window published")).font(.caption).foregroundStyle(Theme.fgDim) + } + } + } + .accessibilityElement(children: .combine) + if summary.bars.isEmpty { + Hint(L("No prices published ahead yet.")) + } else { + strip(summary, unit: unit) + .frame(height: 64) + if let mean = summary.meanMinor { + Hint(AppWords.format("Dotted line: average ahead, %@", "\(PriceStrip.text(mean, prices.currency)) \(unit.label)"), tone: Theme.fgMuted) + } + } + } else { + HStack { + Text(L("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(L("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..99158c1 --- /dev/null +++ b/appleApp/FTW/UI/RootView.swift @@ -0,0 +1,182 @@ +import FTWKit +import SwiftUI + +/// The shell: pairing when there is nothing to show, else one home. Paints +/// from what is on the phone and never waits on the network. +struct RootView: View { + let app: AppModel + + 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) + .onAppear { LaunchClock.mark("first frame") } + } +} + +/// Where a home can go from Now. M1 has one place: the car. +enum HomeRoute: Hashable { + /// One charger's car; nil is the first. + case car(String?) + + /// Where a home opens. Debug builds take `-FTWTab car` and the like, so + /// every screen of the demo can be photographed. + static var initial: [HomeRoute] { + #if DEBUG + return UserDefaults.standard.string(forKey: "FTWTab") == "car" ? [.car(nil)] : [] + #else + return [] + #endif + } +} + +struct HomeView: View { + let app: AppModel + let home: HomeModels + @State private var path: [HomeRoute] = HomeRoute.initial + /// The car row on Now grows into the car view. + @Namespace private var zoom + + var body: some View { + VStack(spacing: 0) { + if app.isDemo { + DemoBand(site: home.site) { app.exitDemo() } + } else { + Band(app: app, site: home.site) + } + NavigationStack(path: $path) { + Screen(app: app) { + NowView(app: app, home: home, zoom: zoom) + } + .toolbar(.hidden, for: .navigationBar) + .navigationDestination(for: HomeRoute.self) { route in + switch route { + case .car(let id): + CarView(app: app, home: home, id: id) + .navigationTransition(.zoom(sourceID: id ?? CarRow.firstID, in: zoom)) + } + } + } + } + // Now and the car read the chargers from one model, so a screen + // appearing asks the box once. + .onAppear { home.loadpoints.activate() } + .onDisappear { home.loadpoints.deactivate() } + } +} + +/// The freshness band, in its own view so a frame redraws the band and not +/// the screen under it. +private struct Band: View { + let app: AppModel + let site: SiteModel + + var body: some View { + FreshnessBandView(band: site.freshness(noCarrier: app.connectHelp != nil), frameAtMs: site.lastFrameAtMs) + } +} + +/// One scrolling page, with pull to refresh. +struct Screen: View { + let app: AppModel + @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) + // One line of chrome above every screen: it must not crowd out the + // screen at the largest text sizes. + .dynamicTypeSize(...DynamicTypeSize.xxxLarge) + } + + private var color: Color { + switch band.tone { + case .live: return Theme.freshLive + case .stale: return Theme.freshStale + case .reaching, .lost: return Theme.freshLost + case .busy: return Theme.freshStale + } + } +} + +/// 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(L(site.session.phase == .streaming ? "Live demo · simulated home" : "Starting the demo")) + .font(.footnote.weight(.medium)) + .lineLimit(1) + .minimumScaleFactor(0.7) + Spacer() + Button(L("Exit demo"), action: exit) + .font(.footnote.weight(.semibold)) + .lineLimit(1) + .buttonStyle(.quiet) + } + .padding(.horizontal, 16) + .padding(.vertical, 6) + .background(Theme.surfaceSunken.ignoresSafeArea(edges: .top)) + .overlay(alignment: .bottom) { Theme.line.frame(height: 1) } + .dynamicTypeSize(...DynamicTypeSize.xxxLarge) + } +} diff --git a/appleApp/FTW/UI/Theme.swift b/appleApp/FTW/UI/Theme.swift new file mode 100644 index 0000000..59d5261 --- /dev/null +++ b/appleApp/FTW/UI/Theme.swift @@ -0,0 +1,200 @@ +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. The values live in `Theme.xcassets`, one colour +/// set per role with its dark variant, so the system switches them. +enum Theme { + static let surface = Color("ftw.surface") + static let surfaceSunken = Color("ftw.surfaceSunken") + static let surfaceRaised = Color("ftw.surfaceRaised") + static let surfaceElevated = Color("ftw.surfaceElevated") + static let line = Color("ftw.line") + static let fg = Color("ftw.fg") + static let fgDim = Color("ftw.fgDim") + static let fgMuted = Color("ftw.fgMuted") + static let onAccent = Color("ftw.onAccent") + + static let accent = Color("ftw.accent") + static let importing = Color("ftw.importing") + static let exporting = Color("ftw.exporting") + static let generation = Color("ftw.generation") + static let storage = Color("ftw.storage") + static let mobility = Color("ftw.mobility") + + static let freshLive = exporting + static let freshStale = generation + 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) + } + + /// A percentage: even digits, and a % that reads as a sign, not a + /// fixed-width glyph. + static func percent(_ style: Font.TextStyle, weight: Font.Weight = .semibold) -> Font { + .system(style, weight: weight).monospacedDigit() + } + + /// One spring for everything that moves, so the app moves as one thing. + static let spring = Animation.spring(duration: 0.35, bounce: 0.15) +} + +/// A raised card, the web app's `.card`. +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 + var wide = false + @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: wide || 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) } + /// The second choice beside a primary one, as wide as it. + static var secondary: FTWButtonStyle { FTWButtonStyle(kind: .outline, wide: true) } +} + +/// A pressed-button choice between a few options, the web app's range picker. +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) + } +} + +/// The app's words in the phone's language, English or Swedish +/// (FTWKit's `AppWords`). Text built from a String is shown as given. +func L(_ en: String) -> String { AppWords.text(en) } diff --git a/appleApp/FTW/UI/Theme.xcassets/Contents.json b/appleApp/FTW/UI/Theme.xcassets/Contents.json new file mode 100644 index 0000000..c47b5f2 --- /dev/null +++ b/appleApp/FTW/UI/Theme.xcassets/Contents.json @@ -0,0 +1,6 @@ +{ + "info": { + "author": "xcode", + "version": 1 + } +} \ No newline at end of file diff --git a/appleApp/FTW/UI/Theme.xcassets/ftw.accent.colorset/Contents.json b/appleApp/FTW/UI/Theme.xcassets/ftw.accent.colorset/Contents.json new file mode 100644 index 0000000..f2cfe90 --- /dev/null +++ b/appleApp/FTW/UI/Theme.xcassets/ftw.accent.colorset/Contents.json @@ -0,0 +1,38 @@ +{ + "colors": [ + { + "color": { + "color-space": "srgb", + "components": { + "alpha": "1.000", + "red": "0xDA", + "green": "0x7F", + "blue": "0x00" + } + }, + "idiom": "universal" + }, + { + "appearances": [ + { + "appearance": "luminosity", + "value": "dark" + } + ], + "color": { + "color-space": "srgb", + "components": { + "alpha": "1.000", + "red": "0xFF", + "green": "0xAC", + "blue": "0x41" + } + }, + "idiom": "universal" + } + ], + "info": { + "author": "xcode", + "version": 1 + } +} \ No newline at end of file diff --git a/appleApp/FTW/UI/Theme.xcassets/ftw.exporting.colorset/Contents.json b/appleApp/FTW/UI/Theme.xcassets/ftw.exporting.colorset/Contents.json new file mode 100644 index 0000000..cfe7820 --- /dev/null +++ b/appleApp/FTW/UI/Theme.xcassets/ftw.exporting.colorset/Contents.json @@ -0,0 +1,38 @@ +{ + "colors": [ + { + "color": { + "color-space": "srgb", + "components": { + "alpha": "1.000", + "red": "0x00", + "green": "0x96", + "blue": "0x39" + } + }, + "idiom": "universal" + }, + { + "appearances": [ + { + "appearance": "luminosity", + "value": "dark" + } + ], + "color": { + "color-space": "srgb", + "components": { + "alpha": "1.000", + "red": "0x5F", + "green": "0xD3", + "blue": "0x7F" + } + }, + "idiom": "universal" + } + ], + "info": { + "author": "xcode", + "version": 1 + } +} \ No newline at end of file diff --git a/appleApp/FTW/UI/Theme.xcassets/ftw.fg.colorset/Contents.json b/appleApp/FTW/UI/Theme.xcassets/ftw.fg.colorset/Contents.json new file mode 100644 index 0000000..990e793 --- /dev/null +++ b/appleApp/FTW/UI/Theme.xcassets/ftw.fg.colorset/Contents.json @@ -0,0 +1,38 @@ +{ + "colors": [ + { + "color": { + "color-space": "srgb", + "components": { + "alpha": "1.000", + "red": "0x19", + "green": "0x19", + "blue": "0x19" + } + }, + "idiom": "universal" + }, + { + "appearances": [ + { + "appearance": "luminosity", + "value": "dark" + } + ], + "color": { + "color-space": "srgb", + "components": { + "alpha": "1.000", + "red": "0xE8", + "green": "0xE8", + "blue": "0xE8" + } + }, + "idiom": "universal" + } + ], + "info": { + "author": "xcode", + "version": 1 + } +} \ No newline at end of file diff --git a/appleApp/FTW/UI/Theme.xcassets/ftw.fgDim.colorset/Contents.json b/appleApp/FTW/UI/Theme.xcassets/ftw.fgDim.colorset/Contents.json new file mode 100644 index 0000000..ef51122 --- /dev/null +++ b/appleApp/FTW/UI/Theme.xcassets/ftw.fgDim.colorset/Contents.json @@ -0,0 +1,38 @@ +{ + "colors": [ + { + "color": { + "color-space": "srgb", + "components": { + "alpha": "1.000", + "red": "0x4F", + "green": "0x4F", + "blue": "0x4B" + } + }, + "idiom": "universal" + }, + { + "appearances": [ + { + "appearance": "luminosity", + "value": "dark" + } + ], + "color": { + "color-space": "srgb", + "components": { + "alpha": "1.000", + "red": "0xA0", + "green": "0xA0", + "blue": "0xA0" + } + }, + "idiom": "universal" + } + ], + "info": { + "author": "xcode", + "version": 1 + } +} \ No newline at end of file diff --git a/appleApp/FTW/UI/Theme.xcassets/ftw.fgMuted.colorset/Contents.json b/appleApp/FTW/UI/Theme.xcassets/ftw.fgMuted.colorset/Contents.json new file mode 100644 index 0000000..12c1c27 --- /dev/null +++ b/appleApp/FTW/UI/Theme.xcassets/ftw.fgMuted.colorset/Contents.json @@ -0,0 +1,38 @@ +{ + "colors": [ + { + "color": { + "color-space": "srgb", + "components": { + "alpha": "1.000", + "red": "0x68", + "green": "0x68", + "blue": "0x62" + } + }, + "idiom": "universal" + }, + { + "appearances": [ + { + "appearance": "luminosity", + "value": "dark" + } + ], + "color": { + "color-space": "srgb", + "components": { + "alpha": "1.000", + "red": "0x85", + "green": "0x85", + "blue": "0x85" + } + }, + "idiom": "universal" + } + ], + "info": { + "author": "xcode", + "version": 1 + } +} \ No newline at end of file diff --git a/appleApp/FTW/UI/Theme.xcassets/ftw.generation.colorset/Contents.json b/appleApp/FTW/UI/Theme.xcassets/ftw.generation.colorset/Contents.json new file mode 100644 index 0000000..b37c49c --- /dev/null +++ b/appleApp/FTW/UI/Theme.xcassets/ftw.generation.colorset/Contents.json @@ -0,0 +1,38 @@ +{ + "colors": [ + { + "color": { + "color-space": "srgb", + "components": { + "alpha": "1.000", + "red": "0xD4", + "green": "0x85", + "blue": "0x00" + } + }, + "idiom": "universal" + }, + { + "appearances": [ + { + "appearance": "luminosity", + "value": "dark" + } + ], + "color": { + "color-space": "srgb", + "components": { + "alpha": "1.000", + "red": "0xFF", + "green": "0xB0", + "blue": "0x00" + } + }, + "idiom": "universal" + } + ], + "info": { + "author": "xcode", + "version": 1 + } +} \ No newline at end of file diff --git a/appleApp/FTW/UI/Theme.xcassets/ftw.importing.colorset/Contents.json b/appleApp/FTW/UI/Theme.xcassets/ftw.importing.colorset/Contents.json new file mode 100644 index 0000000..866f7d5 --- /dev/null +++ b/appleApp/FTW/UI/Theme.xcassets/ftw.importing.colorset/Contents.json @@ -0,0 +1,38 @@ +{ + "colors": [ + { + "color": { + "color-space": "srgb", + "components": { + "alpha": "1.000", + "red": "0xCC", + "green": "0x24", + "blue": "0x3D" + } + }, + "idiom": "universal" + }, + { + "appearances": [ + { + "appearance": "luminosity", + "value": "dark" + } + ], + "color": { + "color-space": "srgb", + "components": { + "alpha": "1.000", + "red": "0xFF", + "green": "0x6E", + "blue": "0x74" + } + }, + "idiom": "universal" + } + ], + "info": { + "author": "xcode", + "version": 1 + } +} \ No newline at end of file diff --git a/appleApp/FTW/UI/Theme.xcassets/ftw.line.colorset/Contents.json b/appleApp/FTW/UI/Theme.xcassets/ftw.line.colorset/Contents.json new file mode 100644 index 0000000..2776b7d --- /dev/null +++ b/appleApp/FTW/UI/Theme.xcassets/ftw.line.colorset/Contents.json @@ -0,0 +1,38 @@ +{ + "colors": [ + { + "color": { + "color-space": "srgb", + "components": { + "alpha": "1.000", + "red": "0xCE", + "green": "0xCE", + "blue": "0xC7" + } + }, + "idiom": "universal" + }, + { + "appearances": [ + { + "appearance": "luminosity", + "value": "dark" + } + ], + "color": { + "color-space": "srgb", + "components": { + "alpha": "1.000", + "red": "0x2A", + "green": "0x2A", + "blue": "0x2A" + } + }, + "idiom": "universal" + } + ], + "info": { + "author": "xcode", + "version": 1 + } +} \ No newline at end of file diff --git a/appleApp/FTW/UI/Theme.xcassets/ftw.mobility.colorset/Contents.json b/appleApp/FTW/UI/Theme.xcassets/ftw.mobility.colorset/Contents.json new file mode 100644 index 0000000..771e0e2 --- /dev/null +++ b/appleApp/FTW/UI/Theme.xcassets/ftw.mobility.colorset/Contents.json @@ -0,0 +1,38 @@ +{ + "colors": [ + { + "color": { + "color-space": "srgb", + "components": { + "alpha": "1.000", + "red": "0x7F", + "green": "0x5B", + "blue": "0xB6" + } + }, + "idiom": "universal" + }, + { + "appearances": [ + { + "appearance": "luminosity", + "value": "dark" + } + ], + "color": { + "color-space": "srgb", + "components": { + "alpha": "1.000", + "red": "0xCC", + "green": "0xA8", + "blue": "0xFF" + } + }, + "idiom": "universal" + } + ], + "info": { + "author": "xcode", + "version": 1 + } +} \ No newline at end of file diff --git a/appleApp/FTW/UI/Theme.xcassets/ftw.onAccent.colorset/Contents.json b/appleApp/FTW/UI/Theme.xcassets/ftw.onAccent.colorset/Contents.json new file mode 100644 index 0000000..d67944e --- /dev/null +++ b/appleApp/FTW/UI/Theme.xcassets/ftw.onAccent.colorset/Contents.json @@ -0,0 +1,38 @@ +{ + "colors": [ + { + "color": { + "color-space": "srgb", + "components": { + "alpha": "1.000", + "red": "0x0A", + "green": "0x0A", + "blue": "0x0A" + } + }, + "idiom": "universal" + }, + { + "appearances": [ + { + "appearance": "luminosity", + "value": "dark" + } + ], + "color": { + "color-space": "srgb", + "components": { + "alpha": "1.000", + "red": "0x0A", + "green": "0x0A", + "blue": "0x0A" + } + }, + "idiom": "universal" + } + ], + "info": { + "author": "xcode", + "version": 1 + } +} \ No newline at end of file diff --git a/appleApp/FTW/UI/Theme.xcassets/ftw.storage.colorset/Contents.json b/appleApp/FTW/UI/Theme.xcassets/ftw.storage.colorset/Contents.json new file mode 100644 index 0000000..25f9aac --- /dev/null +++ b/appleApp/FTW/UI/Theme.xcassets/ftw.storage.colorset/Contents.json @@ -0,0 +1,38 @@ +{ + "colors": [ + { + "color": { + "color-space": "srgb", + "components": { + "alpha": "1.000", + "red": "0x00", + "green": "0x8F", + "blue": "0xA8" + } + }, + "idiom": "universal" + }, + { + "appearances": [ + { + "appearance": "luminosity", + "value": "dark" + } + ], + "color": { + "color-space": "srgb", + "components": { + "alpha": "1.000", + "red": "0x13", + "green": "0xDC", + "blue": "0xF6" + } + }, + "idiom": "universal" + } + ], + "info": { + "author": "xcode", + "version": 1 + } +} \ No newline at end of file diff --git a/appleApp/FTW/UI/Theme.xcassets/ftw.surface.colorset/Contents.json b/appleApp/FTW/UI/Theme.xcassets/ftw.surface.colorset/Contents.json new file mode 100644 index 0000000..b9bc37b --- /dev/null +++ b/appleApp/FTW/UI/Theme.xcassets/ftw.surface.colorset/Contents.json @@ -0,0 +1,38 @@ +{ + "colors": [ + { + "color": { + "color-space": "srgb", + "components": { + "alpha": "1.000", + "red": "0xF4", + "green": "0xF4", + "blue": "0xF2" + } + }, + "idiom": "universal" + }, + { + "appearances": [ + { + "appearance": "luminosity", + "value": "dark" + } + ], + "color": { + "color-space": "srgb", + "components": { + "alpha": "1.000", + "red": "0x0D", + "green": "0x0D", + "blue": "0x0D" + } + }, + "idiom": "universal" + } + ], + "info": { + "author": "xcode", + "version": 1 + } +} \ No newline at end of file diff --git a/appleApp/FTW/UI/Theme.xcassets/ftw.surfaceElevated.colorset/Contents.json b/appleApp/FTW/UI/Theme.xcassets/ftw.surfaceElevated.colorset/Contents.json new file mode 100644 index 0000000..8da8c03 --- /dev/null +++ b/appleApp/FTW/UI/Theme.xcassets/ftw.surfaceElevated.colorset/Contents.json @@ -0,0 +1,38 @@ +{ + "colors": [ + { + "color": { + "color-space": "srgb", + "components": { + "alpha": "1.000", + "red": "0xFF", + "green": "0xFF", + "blue": "0xFF" + } + }, + "idiom": "universal" + }, + { + "appearances": [ + { + "appearance": "luminosity", + "value": "dark" + } + ], + "color": { + "color-space": "srgb", + "components": { + "alpha": "1.000", + "red": "0x1E", + "green": "0x1E", + "blue": "0x1E" + } + }, + "idiom": "universal" + } + ], + "info": { + "author": "xcode", + "version": 1 + } +} \ No newline at end of file diff --git a/appleApp/FTW/UI/Theme.xcassets/ftw.surfaceRaised.colorset/Contents.json b/appleApp/FTW/UI/Theme.xcassets/ftw.surfaceRaised.colorset/Contents.json new file mode 100644 index 0000000..13843e1 --- /dev/null +++ b/appleApp/FTW/UI/Theme.xcassets/ftw.surfaceRaised.colorset/Contents.json @@ -0,0 +1,38 @@ +{ + "colors": [ + { + "color": { + "color-space": "srgb", + "components": { + "alpha": "1.000", + "red": "0xFA", + "green": "0xFA", + "blue": "0xF8" + } + }, + "idiom": "universal" + }, + { + "appearances": [ + { + "appearance": "luminosity", + "value": "dark" + } + ], + "color": { + "color-space": "srgb", + "components": { + "alpha": "1.000", + "red": "0x16", + "green": "0x16", + "blue": "0x16" + } + }, + "idiom": "universal" + } + ], + "info": { + "author": "xcode", + "version": 1 + } +} \ No newline at end of file diff --git a/appleApp/FTW/UI/Theme.xcassets/ftw.surfaceSunken.colorset/Contents.json b/appleApp/FTW/UI/Theme.xcassets/ftw.surfaceSunken.colorset/Contents.json new file mode 100644 index 0000000..3829a78 --- /dev/null +++ b/appleApp/FTW/UI/Theme.xcassets/ftw.surfaceSunken.colorset/Contents.json @@ -0,0 +1,38 @@ +{ + "colors": [ + { + "color": { + "color-space": "srgb", + "components": { + "alpha": "1.000", + "red": "0xEC", + "green": "0xEC", + "blue": "0xE8" + } + }, + "idiom": "universal" + }, + { + "appearances": [ + { + "appearance": "luminosity", + "value": "dark" + } + ], + "color": { + "color-space": "srgb", + "components": { + "alpha": "1.000", + "red": "0x10", + "green": "0x10", + "blue": "0x10" + } + }, + "idiom": "universal" + } + ], + "info": { + "author": "xcode", + "version": 1 + } +} \ No newline at end of file diff --git a/appleApp/FTWKit/Package.swift b/appleApp/FTWKit/Package.swift new file mode 100644 index 0000000..4ac35c7 --- /dev/null +++ b/appleApp/FTWKit/Package.swift @@ -0,0 +1,38 @@ +// swift-tools-version:6.2 +// +// Everything the iPhone app does that is not a pixel, in three modules: +// +// - FTWKit: pairing, the passkey derivations, Noise IK, frames, the relay, +// the session and the state each screen reads. The app alone links it. +// - FTWShared: plain `Sendable` values the app shares with its widgets and, +// later, a notification extension. The App Group cache, its records and +// the formatters a widget needs. Nothing on the main actor, no keys. +// - FTWDemo: a simulated box in this process, for the demo, App Review and +// previews. Kept out of FTWKit so the product module holds no fake box. +// +// CryptoKit provides the primitives. macOS is listed only so `swift test` +// runs on a Mac; the app itself is iPhone only. + +import PackageDescription + +let package = Package( + name: "FTWKit", + defaultLocalization: "en", + platforms: [.iOS(.v26), .macOS(.v26)], + products: [ + .library(name: "FTWKit", targets: ["FTWKit"]), + .library(name: "FTWShared", targets: ["FTWShared"]), + .library(name: "FTWDemo", targets: ["FTWDemo"]), + ], + targets: [ + .target(name: "FTWShared", resources: [.process("Resources")]), + .target(name: "FTWKit", dependencies: ["FTWShared"]), + .target(name: "FTWDemo", dependencies: ["FTWKit"]), + .testTarget( + name: "FTWKitTests", + dependencies: ["FTWKit", "FTWDemo"], + resources: [.copy("Resources")] + ), + .testTarget(name: "FTWSharedTests", dependencies: ["FTWShared"]), + ] +) diff --git a/appleApp/FTWKit/Sources/FTWDemo/SimulatedBox.swift b/appleApp/FTWKit/Sources/FTWDemo/SimulatedBox.swift new file mode 100644 index 0000000..a3467df --- /dev/null +++ b/appleApp/FTWKit/Sources/FTWDemo/SimulatedBox.swift @@ -0,0 +1,714 @@ +import Foundation +import FTWKit + +/// A box that lives in this process: the other end of the protocol, for the +/// demo and for the tests. It speaks frames exactly as a box does, holds a +/// `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] = [:] + 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 + /// How long the box takes from ack to result: the demo waits as a + /// charger write does, so the receipt has a moment to show. + public var resultDelayMs: Double = 0 + /// A Core with srcfl/ftw#1586 reads and sends the ready time as + /// `time_of_day_min` on the household clock. Off, it is a Core that + /// knows only `time_of_day_min_utc`. + public var readsHouseholdClock = true + /// The last schedule body the app sent, as sent. + public private(set) var lastScheduleBody: JSON? + /// The box the demo is showing, for the demo's own controls (plug the + /// car in or out). Nil outside the demo. + public private(set) static weak var current: SimulatedBox? + public private(set) var receivedCommands: [String] = [] + /// Every API request, as "METHOD /path", so a test can count reads. + public private(set) var receivedRequests: [String] = [] + public private(set) var stepUpsSeen = 0 + + public nonisolated static let modes: [ModeInfo] = [ + 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"), + ] + + /// What a box with a meter, an inverter, a battery and a charger + /// offers. Registry names only; `ContractTests` checks. + public nonisolated static let caps = [ + "status.core", "status.drivers", "history.5m", "history.1h", "history.etag", "cmd.lease", + "cmd.readback", "der.battery", "der.ev", "plan.dispatch", "price.spot", "api.passthrough", + ] + + public init(scheduler: Scheduler, house: SimulatedHouse = SimulatedHouse(), requireStepUp: Bool = false, uptimeAtStartMs: Double = 3_600_000) { + self.scheduler = scheduler + self.house = house + self.requireStepUp = requireStepUp + bootedAtMs = scheduler.nowMs - uptimeAtStartMs + house.step(nowMs: scheduler.nowMs, dtMs: 1_000) + } + + 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 + } + + // 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 "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 "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(Self.caps.map { .text($0) })), + ("capsHash", .text("demo")), + ("modes", .array(Self.modes.map { .map([("key", .text($0.key)), ("label", .text($0.label)), ("tooltip", .text($0.tooltip)), ("tier", .text($0.tier))]) })), + ("role", .text(Contract.roleOwner)), + ("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: Prices + + 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: 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 } + if resultDelayMs > 0 { + scheduler.after(resultDelayMs) { [weak self] in self?.act(cmdId, op, args) } + } else { + act(cmdId, op, args) + } + } + + private func act(_ cmdId: String, _ op: String, _ args: CBOR) { + switch op { + case Contract.opSetMode: + guard let mode = args["mode"]?.string, Self.modes.contains(where: { $0.key == mode }) else { + result(cmdId, "rejected", error: ("E_PRECONDITION", [])) + return + } + house.modeKey = mode + controlRev += 1 + result(cmdId, "applied", observed: Double(Self.modes.firstIndex { $0.key == mode } ?? 0)) + 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" + house.carReportedAtMs = house.carReportedAtMs.map { min($0, scheduler.nowMs - 1) } + result(cmdId, "applied", observed: house.carSocFraction) + case Contract.opLoadpointSurplusOnlySet: + house.surplusOnly = args["surplus_only"]?.bool ?? false + 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/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/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) } + receivedRequests.append("\(method) \(path)") + + 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"): + // The household clock when sent, else the UTC minute read on + // today's date: what Core does. + lastScheduleBody = body + let today = Date(timeIntervalSince1970: now / 1000) + let fromUTC = EVText.clockFromUTC(Int(body?["time_of_day_min_utc"]?.number ?? 300), at: today, zone: house.timeZone) + let clock = readsHouseholdClock ? body?["time_of_day_min"]?.number.map { Int($0) } ?? fromUTC : fromUTC + house.schedule = ( + body?["soc"]?.number ?? 0.8, + clock, + 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]) + default: + return (404, [:]) + } + } + + func loadpointJSON() -> JSON { + let h = house + let now = scheduler.nowMs + // A car that reports counts as the car's word for five minutes, as + // in Core; after that its report is shown, dated, and FTW estimates. + var socSource = h.carSocSource + if let at = h.carReportedAtMs, socSource == "vehicle", now - at > 5 * 60_000 { socSource = "inferred" } + var lp: JSON = [ + "id": "garage", + "driver_name": "easee", + "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.carPluggedIn ? socSource : ""), + "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 at = h.carReportedAtMs, h.carPluggedIn { + let age = max(0, (now - at) / 1000).rounded() + lp = lp.setting("vehicle_driver", "demo_car") + lp = lp.setting("vehicle_soc", .number(h.carSocFraction)) + lp = lp.setting("vehicle_soc_age_s", .number(age)) + lp = lp.setting("vehicle_stale", .bool(age > 15 * 60)) + } + if let manual = h.manualHoldW { + let since = h.manualSinceMs ?? now + let state: String + if manual == 0 { + state = "paused" + } else if h.evWatts > 100 { + state = "charging" + } else if !h.holdActing { + state = now - since < h.chargerRampMs / 2 ? "sent" : "accepted" + } else { + state = "not_drawing" + } + lp = lp.setting("manual", [ + "active": true, + "state": .string(state), + "requested_w": .number(manual), + "requested_a": .number((manual / 690).rounded()), + "started_at_ms": .number(since), + ]) + } + if let s = h.schedule { + var schedule: JSON = [ + "soc": .number(s.socFraction), + // Today's UTC minute of the household clock, as Core answers. + "time_of_day_min_utc": .number(Double(EVText.minuteUTC(clock: s.clockMin, at: Date(timeIntervalSince1970: now / 1000), zone: h.timeZone))), + "recurring": .bool(s.recurring), "days": .number(Double(s.days)), "surplus_unlock_bat_soc": .number(s.surplusUnlock), + ] + if readsHouseholdClock { schedule = schedule.setting("time_of_day_min", .number(Double(s.clockMin))) } + lp = lp.setting("schedule", schedule) + lp = lp.setting("target_soc", .number(s.socFraction)) + if let deadline = h.deadlineMs(now) { + lp = lp.setting("target_time", .string(ISO8601DateFormatter().string(from: Date(timeIntervalSince1970: deadline / 1000)))) + } + } + if let p = h.carPlan(now), p.endMs > p.startMs, p.endMs > now { + lp = lp.setting("plan_windows", [["start_ms": .number(p.startMs.rounded()), "end_ms": .number(p.endMs.rounded()), "wh": .number(p.wh)]]) + lp = lp.setting("plan_next_start_ms", .number(p.startMs.rounded())) + lp = lp.setting("plan_next_end_ms", .number(p.endMs.rounded())) + lp = lp.setting("plan_total_wh", .number(p.wh)) + } else { + lp = lp.setting("plan_windows", []) + } + 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 } + // Frames in flight, oldest first. A real socket keeps order; timers + // with the same delay need not fire in order, so each timer delivers + // the oldest frame rather than its own. + private var toApp: [Bytes] = [] + private var toBox: [Bytes] = [] + + public init(box: SimulatedBox, scheduler: Scheduler, latencyMs: Double = 120) { + self.box = box + self.scheduler = scheduler + self.latencyMs = latencyMs + box.send = { [weak self] frame in + guard let self else { return } + self.toApp.append(frame) + self.scheduler.after(self.latencyMs) { [weak self] in + guard let self, !self.closed, !self.toApp.isEmpty else { return } + self.onFrame(self.toApp.removeFirst()) + } + } + 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 } + toBox.append(frame) + scheduler.after(latencyMs) { [weak self] in + guard let self, !self.closed, !self.toBox.isEmpty else { return } + self.box.receive(self.toBox.removeFirst()) + } + } + + /// Pull to refresh in the demo: the house moves on a second. + public func wake() { + box.tick() + } + + public func close(reason: String) { + if closed { return } + closed = true + box.stop() + status = .closed(reason: reason, retryable: false) + } +} + +extension SimulatedBox { + /// The demo as `AppModel` takes it: a fresh simulated home behind a + /// loopback carrier, a car just plugged in, and a box and charger that + /// take a moment to answer, as real ones do. + public static func loopback(_ scheduler: Scheduler) -> Carrier { + let box = SimulatedBox(scheduler: scheduler) + box.resultDelayMs = 900 + box.house.chargerRampMs = 4_000 + #if DEBUG + // For screenshots: `-FTWDemoCar unplugged` and the like. + if let name = UserDefaults.standard.string(forKey: "FTWDemoCar"), let car = DemoCar(rawValue: name) { + car.apply(to: box.house, nowMs: scheduler.nowMs) + } + #endif + current = box + return LoopbackCarrier(box: box, scheduler: scheduler) + } + + /// Plug the demo car in or out. + public func setCarPluggedIn(_ on: Bool) { + if on { house.plugIn() } else { house.unplug() } + } +} + +/// The car situations the demo can open in, so each screen of the car view +/// can be shown without a box. +public enum DemoCar: String, CaseIterable, Sendable { + /// Plugged in, unread: FTW assumes 20 %. The default. + case assumed + case unplugged + /// A level someone gave FTW, charging since. + case estimated + /// A car that reports its own level, just now. + case car + /// A car that last reported 40 minutes ago. + case carOld + /// A goal the night is too short for. + case late + /// No goal yet. + case noGoal + /// Charge now running. + case charging + + @MainActor + public func apply(to h: SimulatedHouse, nowMs: Double) { + switch self { + case .assumed: break + case .unplugged: h.unplug() + case .estimated: + h.carSocFraction = 0.46 + h.carSocSource = "inferred" + case .car: + h.carReportedAtMs = nowMs - 40_000 + h.carSocFraction = 0.64 + h.carSocSource = "vehicle" + case .carOld: + h.carReportedAtMs = nowMs - 40 * 60_000 + h.carSocFraction = 0.58 + h.carSocSource = "vehicle" + case .late: + h.carSocFraction = 0.35 + h.carSocSource = "inferred" + h.schedule = (0.8, 3 * 60, true, 0, 0) + case .noGoal: + h.schedule = nil + case .charging: + h.carSocFraction = 0.46 + h.carSocSource = "inferred" + h.manualHoldW = 11_000 + } + } +} diff --git a/appleApp/FTWKit/Sources/FTWDemo/SimulatedHouse.swift b/appleApp/FTWKit/Sources/FTWDemo/SimulatedHouse.swift new file mode 100644 index 0000000..8f62cc7 --- /dev/null +++ b/appleApp/FTWKit/Sources/FTWDemo/SimulatedHouse.swift @@ -0,0 +1,263 @@ +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. It arrives plugged in and unread, so the box assumes 20 %, + // which is the moment the car view is for. + public var carPluggedIn = true + public var carSocFraction: Double = 0.2 + public var carSocSource = "assumed" + public var carCapacityWh: Double = 64_000 + /// A car that reports its own level: when it last did. Nil for a car + /// FTW cannot read. + public var carReportedAtMs: Double? + public private(set) var evW: Double = 0 + public var manualHoldW: Double? { + didSet { if manualHoldW != oldValue { manualSinceMs = manualHoldW == nil ? nil : lastStepMs } } + } + /// When the current hold began. The demo charger takes a few seconds to + /// act on it, as a real one does. + public private(set) var manualSinceMs: Double? + public var chargerRampMs: Double = 0 + public var surplusOnly = false + public var boostUntilMs: Double? + public var boostReserve: Double = 0.3 + public var lastBoostStop: String? + /// The goal on the household clock, as Core keeps it. + public var schedule: (socFraction: Double, clockMin: Int, recurring: Bool, days: Int, surplusUnlock: Double)? = (0.8, 7 * 60, true, 0b0011111, 0) + public private(set) var sessionWh: Double = 0 + private var lastStepMs: Double = 0 + + /// The cable goes in: a new session, nothing known about the car. + public func plugIn() { + carPluggedIn = true + carSocFraction = 0.2 + carSocSource = carReportedAtMs == nil ? "assumed" : "vehicle" + sessionWh = 0 + manualHoldW = nil + } + + /// The cable comes out. An unplug releases a hold, as in Core. + public func unplug() { + carPluggedIn = false + manualHoldW = nil + boostUntilMs = nil + evW = 0 + } + + /// Whether the charger has acted on the hold yet. + public var holdActing: Bool { + guard let since = manualSinceMs else { return false } + return lastStepMs - since >= chargerRampMs + } + + /// The next time the goal's clock comes round on an allowed day. + public func deadlineMs(_ nowMs: Double) -> Double? { + guard let s = schedule else { return nil } + var cal = Calendar(identifier: .gregorian) + cal.timeZone = timeZone + let now = Date(timeIntervalSince1970: nowMs / 1000) + let today = cal.startOfDay(for: now) + for i in 0..<15 { + guard let day = cal.date(byAdding: .day, value: i, to: today), + let t = cal.date(bySettingHour: s.clockMin / 60, minute: s.clockMin % 60, second: 0, of: day) else { continue } + let iso = (cal.component(.weekday, from: day) + 5) % 7 + if s.days != 0, s.days & (1 << iso) == 0 { continue } + if t > now { return t.timeIntervalSince1970 * 1000 } + } + return nil + } + + public static let chargerW: Double = 11_000 + public static let chargeEfficiency = 0.92 + + /// The plan for the car: the night's cheap hours from 01:00, as many as + /// the goal needs, never past the deadline. `shortfallWh` is what the + /// battery still lacks at the deadline, the way Core's plan says it. + public func carPlan(_ nowMs: Double) -> (startMs: Double, endMs: Double, wh: Double, shortfallWh: Double)? { + guard carPluggedIn, manualHoldW == nil, !surplusOnly, let s = schedule, let deadline = deadlineMs(nowMs) else { return nil } + let needWh = max(0, (s.socFraction - carSocFraction) * carCapacityWh) + guard needWh > 1 else { return (nowMs, nowMs, 0, 0) } + let night = deadline - (hourOfDay(deadline) - 1) * 3_600_000 + let start = max(nowMs, night < deadline ? night : nowMs) + let hours = max(0, min((deadline - start) / 3_600_000, needWh / (Self.chargerW * Self.chargeEfficiency))) + let gained = hours * Self.chargerW * Self.chargeEfficiency + return (start, start + hours * 3_600_000, (hours * Self.chargerW).rounded(), max(0, needWh - gained).rounded()) + } + + // Energy since local midnight, integrated. + public private(set) var todayImportWh: Double = 6_100 + 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" + } + lastStepMs = nowMs + if !carPluggedIn || carSocFraction >= 0.999 { + evW = 0 + } else if let hold = manualHoldW { + evW = holdActing ? hold : 0 + } else if boostUntilMs != nil { + evW = 7_400 + } else if surplusOnly { + let spare = max(0, -pvW - baseLoadW - 200) + evW = spare >= 1_400 ? min(spare, 11_000).rounded() : 0 + } else if let plan = carPlan(nowMs), nowMs >= plan.startMs, nowMs < plan.endMs { + evW = Self.chargerW + } else { + evW = 0 + } + if evW > 0 { + let wh = evW * dtMs / 3_600_000 + sessionWh += wh + carSocFraction = min(1, carSocFraction + wh * Self.chargeEfficiency / carCapacityWh) + // A car that reports keeps reporting while it charges. + if carReportedAtMs != nil, carSocSource == "vehicle" { carReportedAtMs = nowMs } + } + + // 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 } + } + + /// 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/Carrier/Carrier.swift b/appleApp/FTWKit/Sources/FTWKit/Carrier/Carrier.swift new file mode 100644 index 0000000..f3fdce7 --- /dev/null +++ b/appleApp/FTWKit/Sources/FTWKit/Carrier/Carrier.swift @@ -0,0 +1,66 @@ +import Foundation +import FTWShared + +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 + } + + /// The relay turned this phone away with 4429: the home already has as + /// many phones watching as the relay serves, or the relay is shedding + /// load. Someone else is looking; nothing is broken. + public var isBusy: Bool { + if case .closed(let reason, _) = self { return reason == RelayCarrier.closeReason(RelayCarrier.closeBusy) } + return false + } + + var phaseName: String { + switch self { + case .connecting: return "connecting" + 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..72f725e --- /dev/null +++ b/appleApp/FTWKit/Sources/FTWKit/Carrier/NoiseCarrier.swift @@ -0,0 +1,234 @@ +import Foundation +import FTWShared + +/// 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..aaac9f8 --- /dev/null +++ b/appleApp/FTWKit/Sources/FTWKit/Carrier/RelayCarrier.swift @@ -0,0 +1,236 @@ +import Foundation +import FTWShared + +/// A WebSocket to Sourceful's blind relay, wrapped so nothing above it ever +/// learns the network went away. There is no reconnect button in this app +/// 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 nonisolated static let closeBadJoin = 4400 + public nonisolated static let closeEpoch = 4409 + public nonisolated static let closeRotated = 4410 + public nonisolated static let closeBusy = 4429 + static let ctrlReady = "ready" + static let ctrlGone = "gone" + + 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) + } + } + + nonisolated 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/Exports.swift b/appleApp/FTWKit/Sources/FTWKit/Exports.swift new file mode 100644 index 0000000..3981723 --- /dev/null +++ b/appleApp/FTWKit/Sources/FTWKit/Exports.swift @@ -0,0 +1,2 @@ +// The app reads FTWShared's values through FTWKit, so it imports one module. +@_exported import FTWShared diff --git a/appleApp/FTWKit/Sources/FTWKit/Format/CommandText.swift b/appleApp/FTWKit/Sources/FTWKit/Format/CommandText.swift new file mode 100644 index 0000000..67de5b7 --- /dev/null +++ b/appleApp/FTWKit/Sources/FTWKit/Format/CommandText.swift @@ -0,0 +1,38 @@ +import Foundation + +/// Sentences for a command's fate. One table for every op: the codes are +/// about the door, not about what was asked. +public enum CommandText { + public static func help(_ r: CmdResult) -> String { + switch r.errorCode { + case "E_PRECONDITION": return "Your home changed while that was sending. Have another go." + case "E_CONFLICT": return "Something else changed the setting first. Try again." + case "E_SCOPE_DENIED": return "You don't have permission to change how this home runs." + case "E_CMD_EXPIRED": return "That took too long to reach your box. Try again." + case "E_BOOTING": return "Your box is still starting. Give it a minute." + case "E_UNAVAILABLE": + if r.errorArgs["op"]?.string == Contract.opLoadpointSurplusOnlySet { return "Solar rule not saved. Your previous choice is unchanged. Try again." } + return "Your box can't reach the charger right now. Try again shortly." + default: return "That didn't go through. Try again." + } + } + + /// The boost's own refusals before the door's. + public static func boostHelp(_ r: CmdResult) -> String { + if r.errorCode == "E_UNAVAILABLE", r.errorArgs["op"]?.string != nil { + return "Your box won't boost right now — the house battery or the site isn't ready for it." + } + if r.errorCode == "E_UNKNOWN_OP", r.errorArgs["arg"]?.string == "lease" { + return "Your box refused that reserve and time. Try other values." + } + return help(r) + } + + /// A level for a car that is not on the cable is refused by name. + public static func socHelp(_ r: CmdResult) -> String { + if r.errorCode == "E_UNAVAILABLE", r.errorArgs["reason"]?.string == "unplugged" { + return "Plug the car in first — your box has no car to set a level for." + } + return help(r) + } +} diff --git a/appleApp/FTWKit/Sources/FTWKit/Format/EV.swift b/appleApp/FTWKit/Sources/FTWKit/Format/EV.swift new file mode 100644 index 0000000..2c91992 --- /dev/null +++ b/appleApp/FTWKit/Sources/FTWKit/Format/EV.swift @@ -0,0 +1,1085 @@ +import Foundation +import FTWShared + +/// One charger as `/api/loadpoints` serves it, read tolerantly, and the +/// sentences that describe it. No sentence claims what the box has not +/// 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 + /// The ready time as the box sends it: the UTC minute of day of that + /// time on today's date. + public var timeOfDayMinUTC: Int + /// The ready time on the household clock, from a box that sends it. + public var clockMin: Int? + public var surplusUnlockPct: Double + public var recurring: Bool + /// 7-bit weekday mask, bit 0 = Monday. Zero means every day. + public var days: Int + /// The goal is the car's own limit rather than a percentage. + public var finishAtVehicleLimit: Bool + + public init(socPct: Double, timeOfDayMinUTC: Int, clockMin: Int?, surplusUnlockPct: Double, recurring: Bool, days: Int, finishAtVehicleLimit: Bool) { + self.socPct = socPct + self.timeOfDayMinUTC = timeOfDayMinUTC + self.clockMin = clockMin + self.surplusUnlockPct = surplusUnlockPct + self.recurring = recurring + self.days = days + self.finishAtVehicleLimit = finishAtVehicleLimit + } + } + + public struct Manual: Equatable, Sendable { + 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 + /// How old the car's own report of its level was when the box answered. + public var vehicleSocAgeS: Double? + /// The car's own report, kept apart from the box's estimate. + public var vehicleSocPct: Double? + public var vehicleDriver: String + public var vehicleStale: Bool + public var vehicleCapacityWh: Double? + public var capacitySource: String + public var socRetention: String + public var chargingDeclined: Bool + public var targetSocPct: Double? + /// The deadline the box plans for, on the phone's clock. + public var targetTimeMs: Double? + public var goalComplete: Bool + public var sessionWh: Double + public var minChargeW: Double? + public var maxChargeW: Double? + 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 planTotalWh: 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? + + /// When the level was read, on the phone's clock. A level from the car + /// is as old as the car's last report; any other level is the box's + /// account as of its answer. The box's own wall clock is never used: a + /// box without a clock source reads 1970. + public func socReadAtMs(answeredAtMs: Double) -> Double? { + guard socPct != nil else { return nil } + if socSource == "vehicle", let age = vehicleSocAgeS, age.isFinite, age >= 0 { + return answeredAtMs - age * 1000 + } + return answeredAtMs + } + + /// A state of charge off the wire, whole percent. The box stores + /// fractions and still reads a legacy percent; zero means unset. + static func pct(_ v: JSON?) -> Double? { + guard let f = v?.number, f > 0 else { return nil } + return (f > 1 ? f : f * 100).rounded() + } + + /// An RFC 3339 time from Go. Its zero time means none. + static func ms(_ v: JSON?) -> Double? { + guard let s = v?.string, !s.isEmpty else { return nil } + let f = ISO8601DateFormatter() + f.formatOptions = [.withInternetDateTime, .withFractionalSeconds] + let date = f.date(from: s) ?? { + f.formatOptions = [.withInternetDateTime] + return f.date(from: s) + }() + guard let date, date.timeIntervalSince1970 > 946_684_800 else { return nil } + return date.timeIntervalSince1970 * 1000 + } + + public init(_ w: JSON) { + let num: (String) -> Double? = { w[$0]?.number } + id = w["id"]?.string ?? "" + 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 ?? "" + vehicleSocAgeS = num("vehicle_soc_age_s") + vehicleDriver = w["vehicle_driver"]?.string ?? "" + vehicleStale = w["vehicle_stale"]?.bool == true + if let v = num("vehicle_soc"), v >= 0, v <= 1, !vehicleDriver.isEmpty { + vehicleSocPct = (v * 100).rounded() + } else { + vehicleSocPct = nil + } + vehicleCapacityWh = num("vehicle_capacity_wh") + capacitySource = w["capacity_source"]?.string ?? "" + socRetention = w["soc_retention"]?.string ?? "" + chargingDeclined = w["charging_declined"]?.bool == true + targetSocPct = Loadpoint.pct(w["target_soc"]) ?? num("target_soc_pct") + targetTimeMs = Loadpoint.ms(w["target_time"]) + goalComplete = w["goal_complete"]?.bool == true + sessionWh = max(0, (num("delivered_wh_session") ?? 0).rounded()) + minChargeW = num("min_charge_w") + maxChargeW = num("max_charge_w") + 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") + planTotalWh = num("plan_total_wh") + planPending = w["plan_pending"]?.bool == true + planOutdated = w["plan_outdated"]?.bool == true + inlinePlan = (w["plan_pending"]?.bool != nil && w["plan_outdated"]?.bool != nil) || w["plan_windows"]?.array != nil + 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 limit = s["finish_at_vehicle_limit"]?.bool == true + let socPct = Loadpoint.pct(s["soc"]) ?? s["soc_pct"]?.number + if limit || (socPct ?? 0) > 0 { + let clock = s["time_of_day_min"]?.number.map { Int($0) } + schedule = Schedule( + socPct: socPct ?? 100, + timeOfDayMinUTC: Int(minute), + clockMin: clock.flatMap { (0..<1440).contains($0) ? $0 : nil }, + surplusUnlockPct: (s["surplus_unlock_bat_soc"]?.number ?? 0) * 100, + recurring: s["recurring"]?.bool == true, + days: Int(s["days"]?.number ?? 0) & 0x7f, + finishAtVehicleLimit: limit + ) + } + } + } +} + +// MARK: - Words + +/// The app's own words in English or Swedish. The English sentence is the +/// key, so a missing translation shows English rather than a code. +/// +/// FTWShared keeps its Swedish in a strings file; FTWKit has no resources, +/// so its Swedish lives in the table below. Tests pin a language with +/// `AppWords.$language.withValue(.sv) { … }`. +public enum AppWords { + public enum Language: String, Sendable { + case en, sv + + /// The phone's choice among the two the app speaks. + public static var preferred: Language { + Bundle.preferredLocalizations(from: ["en", "sv"]).first == "sv" ? .sv : .en + } + } + + @TaskLocal public static var language: Language = .preferred + + public static func text(_ en: String) -> String { + language == .sv ? (sv[en] ?? en) : en + } + + /// `%@` in order, or `%1$@` by position, replaced with ready text. + public static func format(_ en: String, _ args: String...) -> String { + var out = text(en) + for (i, a) in args.enumerated() { out = out.replacingOccurrences(of: "%\(i + 1)$@", with: a) } + for a in args { + guard let r = out.range(of: "%@") else { break } + out.replaceSubrange(r, with: a) + } + return out + } + + /// A decimal in the language's own way: "7.2" or "7,2". + public static func decimal(_ text: String) -> String { + language == .sv ? text.replacingOccurrences(of: ".", with: ",") : text + } + + /// "80 %", kept on one line. + public static func percent(_ v: Double) -> String { "\(Int(v.rounded()))\u{00A0}%" } + + /// "7.2 kW" or "7,2 kW". + public static func power(_ watts: Double) -> String { + let p = PowerFormat.parts(watts) + return "\(decimal(p.text))\u{00A0}\(p.unit)" + } + + /// "14 kWh", one decimal below ten. + public static func energy(_ wh: Double) -> String { + let kwh = max(0, wh) / 1000 + return "\(decimal(kwh >= 10 ? String(Int(kwh.rounded())) : PowerFormat.fixed(kwh, 1)))\u{00A0}kWh" + } + + /// "07:00" in a zone, the 24-hour clock both languages use for FTW. + public static func clock(_ ms: Double, zone: TimeZone) -> String { + var cal = Calendar(identifier: .gregorian) + cal.timeZone = zone + let c = cal.dateComponents([.hour, .minute], from: Date(timeIntervalSince1970: ms / 1000)) + return String(format: "%02d:%02d", c.hour ?? 0, c.minute ?? 0) + } + + public static func clock(minute: Int) -> String { + String(format: "%02d:%02d", (minute / 60) % 24, minute % 60) + } + + /// "today 07:00", "tomorrow 07:00", "Tue 07:00", else "12 Oct 07:00". + public static func day(_ ms: Double, nowMs: Double, zone: TimeZone) -> String { + var cal = Calendar(identifier: .gregorian) + cal.timeZone = zone + let at = Date(timeIntervalSince1970: ms / 1000) + let now = Date(timeIntervalSince1970: nowMs / 1000) + let days = cal.dateComponents([.day], from: cal.startOfDay(for: now), to: cal.startOfDay(for: at)).day ?? 0 + let time = clock(ms, zone: zone) + switch days { + case 0: return format("today %@", time) + case 1: return format("tomorrow %@", time) + case 2..<7: return "\(text(EVText.dayLabels[(cal.component(.weekday, from: at) + 5) % 7])) \(time)" + default: + let c = cal.dateComponents([.day, .month], from: at) + return "\(c.day ?? 1) \(text(monthLabels[((c.month ?? 1) + 11) % 12])) \(time)" + } + } + + static let monthLabels = ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"] + + static let sv: [String: String] = [ + // Clock and calendar + "today %@": "i dag %@", + "tomorrow %@": "i morgon %@", + "Mon": "mån", "Tue": "tis", "Wed": "ons", "Thu": "tor", "Fri": "fre", "Sat": "lör", "Sun": "sön", + "Jan": "jan", "Feb": "feb", "Mar": "mar", "Apr": "apr", "May": "maj", "Jun": "jun", + "Jul": "jul", "Aug": "aug", "Sep": "sep", "Oct": "okt", "Nov": "nov", "Dec": "dec", + "every day": "varje dag", + "weekdays": "vardagar", + "weekends": "helger", + "once": "en gång", + "on weekdays": "vardagar", + "on weekends": "helger", + "on %@": "%@", + + // The car view + "Your car": "Bilen", + "Car": "Bilen", + "Plugged in": "Inkopplad", + "Not plugged in": "Inte inkopplad", + "How full is the car?": "Hur full är bilen?", + "Correct the level": "Rätta nivån", + "Plug in the car to tell FTW its level.": "Koppla in bilen för att ange nivån.", + "FTW takes a level only while the car is plugged in.": "FTW tar emot nivån bara när bilen är inkopplad.", + "Level unknown": "Nivån är okänd", + "From the car": "Från bilen", + "From the car, last reported %@": "Från bilen, senast rapporterad %@", + "You set %1$@ at %2$@": "Du angav %1$@ kl. %2$@", + "You set %1$@ at %2$@. FTW adds what has charged since.": "Du angav %1$@ kl. %2$@. FTW räknar in det som laddats sedan.", + "FTW's estimate from energy delivered": "FTW:s uppskattning från laddad energi", + "FTW assumes %@. Nobody has told it the real level.": "FTW antar %@. Ingen har angett den riktiga nivån.", + "FTW assumes": "FTW antar", + "Nobody has told FTW the car's level yet. Choose it below.": "Ingen har angett bilens nivå än. Välj den nedan.", + "from the car": "från bilen", + "reported %@": "rapporterad %@", + "you set": "du angav", + "estimated": "uppskattad", + "not confirmed": "inte bekräftad", + "The car stopped charging. Its level is not confirmed.": "Bilen slutade ladda. Nivån är inte bekräftad.", + "Only correct it if the car shows something else.": "Rätta bara om bilen visar något annat.", + + "Goal": "Mål", + "Ready by": "Klar till", + "%1$@ by %2$@ %3$@": "%1$@ till %2$@ %3$@", + "Charge to the car's limit by %1$@ %2$@": "Ladda till bilens gräns till %1$@ %2$@", + "Next: %@": "Nästa gång: %@", + "Next: %@, home time": "Nästa gång: %@, hemmatid", + "No goal yet. Without one, FTW only charges from spare solar.": "Inget mål än. Utan mål laddar FTW bara med solöverskott.", + "Use %@": "Använd %@", + "Change": "Ändra", + "Save": "Spara", + "Cancel": "Avbryt", + "Remove goal": "Ta bort målet", + "Charge to": "Ladda till", + "Repeat": "Upprepa", + "Weekdays": "Vardagar", + "Every day": "Varje dag", + "Weekends": "Helger", + "Once": "En gång", + "This box has no charger.": "Boxen har ingen laddare.", + "Set %@": "Ange %@", + "Saving the goal…": "Sparar målet…", + "Goal saved. FTW is updating the plan.": "Målet är sparat. FTW räknar om planen.", + "Goal removed.": "Målet är borttaget.", + "Choose at least one day.": "Välj minst en dag.", + + "Updating the plan…": "Planen räknas om…", + "Charging times are unavailable. Your goal is saved.": "Laddtiderna saknas just nu. Målet är sparat.", + "Waiting for tomorrow's prices. Spare solar can charge meanwhile.": "Väntar på morgondagens priser. Solöverskott kan ladda under tiden.", + "Charging %1$@–%2$@, about %3$@.": "Laddar %1$@–%2$@, cirka %3$@.", + "Charging %1$@–%2$@, about %3$@, and %4$@ more times.": "Laddar %1$@–%2$@, cirka %3$@, och %4$@ gånger till.", + "No charging planned yet.": "Ingen laddning planerad än.", + "On track: %1$@ by %2$@.": "Hinner bli klar: %1$@ till %2$@.", + "Will not make it: about %1$@ by %2$@, not %3$@.": "Hinner inte: cirka %1$@ till %2$@, inte %3$@.", + "Goal reached.": "Målet är nått.", + "Counted from FTW's guess of %@.": "Räknat från FTW:s antagande, %@.", + "Counted from the level you gave.": "Räknat från nivån du angav.", + + "Charge now": "Ladda nu", + "Pause": "Pausa", + "Back to the plan": "Tillbaka till planen", + "Charge now runs until you go back to the plan or unplug.": "Ladda nu kör tills du går tillbaka till planen eller kopplar ur.", + "Sent": "Skickat", + "Received": "Mottaget", + "Done": "Utfört", + + // What the charger does + "Charger status is out of date.": "Laddarens status är inaktuell.", + "Waiting for the charger's first report.": "Väntar på laddarens första rapport.", + "Paused by you": "Pausad av dig", + "Charging now at %@": "Laddar nu med %@", + "Charging at %@": "Laddar med %@", + "Charge now sent to the charger": "Ladda nu är skickat till laddaren", + "The charger is starting…": "Laddaren startar…", + "The car is not taking power. Check the car's charge limit.": "Bilen tar ingen ström. Kolla bilens laddgräns.", + "The charger has not responded. Check the charger.": "Laddaren har inte svarat. Kolla laddaren.", + "The main fuse limits charging right now.": "Huvudsäkringen begränsar laddningen just nu.", + "The charger limits the current.": "Laddaren begränsar strömmen.", + "Pause sent. Waiting for the charger to stop.": "Paus skickad. Väntar på att laddaren stannar.", + "The car stopped asking for power.": "Bilen vill inte ta mer ström.", + "Paused for safety: the electricity meter is not reporting.": "Pausad för säkerhets skull: elmätaren rapporterar inte.", + "Paused by the main fuse. It resumes on its own.": "Pausad av huvudsäkringen. Den fortsätter av sig själv.", + "Plugged in · charges at %@": "Inkopplad · laddar kl. %@", + "Plugged in, not charging now": "Inkopplad, laddar inte nu", + "%@ this session": "%@ den här laddningen", + + // A command's outcome + "Sending %@ to FTW…": "Skickar %@ till FTW…", + "FTW received %@.": "FTW har tagit emot %@.", + "FTW now plans from %@.": "FTW planerar nu från %@.", + "FTW kept %@. A newer level came first.": "FTW behöll %@. Ett nyare värde kom före.", + "FTW received %@ but has not confirmed it yet.": "FTW har tagit emot %@ men inte bekräftat det än.", + "Sending Charge now…": "Skickar Ladda nu…", + "Sending Pause…": "Skickar Pausa…", + "Sending Back to the plan…": "Skickar Tillbaka till planen…", + "FTW received it.": "FTW har tagit emot det.", + "FTW received it but has not confirmed the result yet.": "FTW har tagit emot det men inte bekräftat resultatet än.", + "FTW started charging. Waiting for the car to draw power.": "FTW har startat laddningen. Väntar på att bilen tar ström.", + "%@. The plan waits until you go back to it.": "%@. Planen väntar tills du går tillbaka till den.", + "Paused. The car will not charge until you choose.": "Pausad. Bilen laddar inte förrän du väljer.", + "Back to the plan. FTW decides when to charge.": "Tillbaka till planen. FTW bestämmer när bilen laddar.", + "Something else changed charging first. Try again.": "Något annat ändrade laddningen först. Försök igen.", + + // Why not + "Plug the car in first. FTW takes a level only while the car is plugged in.": "Koppla in bilen först. FTW tar emot nivån bara när bilen är inkopplad.", + "FTW cannot reach the charger right now. Try again shortly.": "FTW når inte laddaren just nu. Försök igen om en stund.", + "Something changed at home while this was sending. Try again.": "Något ändrades hemma medan det skickades. Försök igen.", + "Only the home's owner can control charging.": "Bara hemmets ägare kan styra laddningen.", + "That took too long to reach FTW. Try again.": "Det tog för lång tid att nå FTW. Försök igen.", + "FTW is starting. Give it a minute.": "FTW startar. Vänta en minut.", + "Your box cannot do this yet. It may need an update.": "Din box kan inte det här än. Den kan behöva uppdateras.", + "That did not go through. Try again.": "Det gick inte igenom. Försök igen.", + "Did not reach FTW. Try again.": "Nådde inte FTW. Försök igen.", + "Not connected to FTW right now. Try again when the connection is back.": "Inte ansluten till FTW just nu. Försök igen när anslutningen är tillbaka.", + "FTW received it but could not save it for a restart.": "FTW har tagit emot det men kunde inte spara det inför en omstart.", + + // The box's API, said for people + "Your box doesn't have that yet — it may be running older software.": "Din box har inte det här än. Den kan köra äldre programvara.", + "Your box is busy with something else. This will fill in shortly.": "Din box är upptagen med något annat. Det här fylls i snart.", + "Your box can't answer that right now. Still trying.": "Din box kan inte svara just nu. Försöker igen.", + "Only the owner of this home can change that.": "Bara hemmets ägare kan ändra det.", + "Your access to this home was withdrawn by its owner.": "Ägaren har dragit tillbaka din åtkomst till hemmet.", + "That has to be done from the controls on the home screen.": "Det görs med knapparna i appen.", + "That one is only available on your box's own page, from home.": "Det går bara att göra på boxens egen sida, hemifrån.", + "That's more than we can send over your connection — narrow the range.": "Det är mer än anslutningen klarar. Välj ett mindre intervall.", + "Your box needs more proof than this phone can give. Do it on your box.": "Din box kräver mer bevis än telefonen kan ge. Gör det på boxen.", + "That didn't work. Nothing on your box has changed.": "Det fungerade inte. Inget på boxen har ändrats.", + "Someone has to own this home, so the last owner cannot be removed.": "Någon måste äga hemmet, så den sista ägaren kan inte tas bort.", + "That's no longer on your box.": "Det finns inte längre på boxen.", + "Your box refused that from this phone.": "Din box nekade det från den här telefonen.", + "Something changed on your box first. Nothing here was applied.": "Något ändrades på boxen först. Inget härifrån har genomförts.", + "Your box can't answer that yet. Still trying.": "Din box kan inte svara på det än. Försöker igen.", + "Something went wrong on your box. Nothing has changed.": "Något gick fel på boxen. Inget har ändrats.", + "Your box couldn't do that.": "Din box kunde inte göra det.", + "Your box didn't answer. Still trying.": "Din box svarade inte. Försöker igen.", + "Your box sent something this app couldn't read.": "Din box skickade något som appen inte kunde läsa.", + "Choose a valid ready time.": "Välj en giltig klartid.", + "Choose a charge target from 10 to 100 %.": "Välj ett laddmål mellan 10 och 100 %.", + "Not up to date — your box is out of reach": "Inte aktuellt – boxen går inte att nå", + + // Is FTW in control? + "Is FTW in control?": "Styr FTW?", + "Yes, FTW is in control": "Ja, FTW styr", + "Nothing blocks it, and every device reports.": "Inget stoppar FTW, och alla enheter rapporterar.", + "%@ is not reporting": "%@ rapporterar inte", + "%@ devices are not reporting": "%@ enheter rapporterar inte", + "The meter": "Elmätaren", + "The inverter": "Växelriktaren", + "Read at %@": "Läst kl. %@", + "Not live right now": "Inte live just nu", + "Last known %@.": "Senast känt %@.", + "Waiting for your box.": "Väntar på din box.", + "FTW is not in control right now": "FTW styr inte just nu", + "A meter stopped reporting. Your home runs on grid power as usual.": "En mätare har slutat rapportera. Hemmet går på nätet som vanligt.", + "The battery": "Batteriet", + "The charger": "Laddaren", + "A device": "En enhet", + + // Now + "Now": "Nu", + "Market now": "Elpris nu", + "Electricity price": "Elpris", + "incl. fees and VAT": "inkl. avgifter och moms", + "Cheapest 2 h": "Billigast 2 h", + "No 2 h window published": "Inget 2 h-fönster publicerat", + "No prices published ahead yet.": "Inga priser framåt är publicerade än.", + "Dotted line: average ahead, %@": "Streckad linje: snitt framåt, %@", + "Some hours are missing their price.": "Några timmar saknar pris.", + "Tomorrow's rates aren't published yet.": "Morgondagens priser är inte publicerade än.", + "Price to import": "Pris att köpa", + "now": "nu", + "HOME": "HEMMET", + "%@ self-powered today": "%@ egen el i dag", + "Home using %@": "Hemmet använder %@", + "GRID": "ELNÄT", "SOLAR": "SOL", "BATTERY": "BATTERI", "EV CHARGER": "LADDARE", + "balanced": "i balans", "importing": "köper", "exporting": "säljer", "no data": "ingen data", + "idle": "vilar", "charging": "laddar", "discharging": "laddar ur", "observe only": "bara övervakning", + "Device details are out of date.": "Enhetsdetaljerna är inaktuella.", + "Device details are out of date. Showing live totals.": "Enhetsdetaljerna är inaktuella. Visar live-summor.", + + // The shell + "Live demo · simulated home": "Livedemo · simulerat hem", + "Starting the demo": "Startar demon", + "Exit demo": "Avsluta demon", + "Demo": "Demo", + "Plug in the car": "Koppla in bilen", + "Unplug the car": "Koppla ur bilen", + "The demo plugs in a car FTW cannot read, so FTW assumes 20 %.": "Demon kopplar in en bil som FTW inte kan läsa av, så FTW antar 20 %.", + "Your box is starting": "Din box startar", + "This can take a few minutes after an update. Nothing is wrong. Your home appears here when it is ready.": "Det kan ta några minuter efter en uppdatering. Inget är fel. Hemmet visas här när boxen är klar.", + "Access ended": "Åtkomsten är avslutad", + "This session ended.": "Sessionen är avslutad.", + "Your box won't let this phone in?": "Släpper boxen inte in telefonen?", + "Nothing from your box yet": "Inget från din box än", + "This phone is paired to it and keeps trying on its own. Your home appears here as soon as the box answers.": "Telefonen är parkopplad och försöker själv. Hemmet visas här så fort boxen svarar.", + "Get this phone back in": "Släpp in telefonen igen", + "This app is older than your box. Some things are hidden until it updates.": "Appen är äldre än din box. En del döljs tills appen uppdateras.", + ] +} + +// MARK: - Sentences + +public enum EVText { + public static let dayLabels = ["Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"] + public static let manualSaveErrorText = "This choice is active now, but could not be saved for restart. FTW is retrying." + + /// 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 AppWords.text("every day") } + if m == 0b0011111 { return AppWords.text("weekdays") } + if m == 0b1100000 { return AppWords.text("weekends") } + return dayLabels.enumerated().filter { m & (1 << $0.offset) != 0 }.map { AppWords.text($0.element) }.joined(separator: ", ") + } + + /// The days after a time: "on weekdays", "every day", "once". + static func dayPhrase(_ mask: Int, recurring: Bool) -> String { + guard recurring else { return AppWords.text("once") } + let m = mask & 0x7f + if m == 0 || m == 0x7f { return AppWords.text("every day") } + if m == 0b0011111 { return AppWords.text("on weekdays") } + if m == 0b1100000 { return AppWords.text("on weekends") } + return AppWords.format("on %@", days(m)) + } + + // MARK: The household clock + + /// The zone the box keeps its clock in, when it names one; else the + /// phone's. "Local" is what a box without TZ sends, and UTC is what a + /// container without a zone runs in: neither is anyone's home. + public static func householdZone(boxTZ: String?, phone: TimeZone = .current) -> TimeZone { + guard let id = boxTZ, !["", "Local", "UTC", "Etc/UTC", "GMT", "Etc/GMT"].contains(id), let zone = TimeZone(identifier: id) else { + return phone + } + return zone + } + + static func calendar(_ zone: TimeZone) -> Calendar { + var cal = Calendar(identifier: .gregorian) + cal.timeZone = zone + return cal + } + + /// The first instant on `day`'s date at which the clock shows `clock` + /// minutes or later: Core's `wallClock`. A skipped time gives the end of + /// the gap, a repeated one its first pass. + static func wallClock(_ day: Date, clock: Int, zone: TimeZone) -> Date { + let cal = calendar(zone) + let start = cal.startOfDay(for: day) + return cal.date(bySettingHour: clock / 60, minute: clock % 60, second: 0, of: start, matchingPolicy: .nextTime, repeatedTimePolicy: .first, direction: .forward) ?? start + } + + /// A UTC minute of day as the box reads it: the time on `at`'s date in + /// `zone` that has that UTC minute. Core's `clockFromUTC`. + public static func clockFromUTC(_ minuteUTC: Int, at: Date, zone: TimeZone) -> Int { + let m = ((minuteUTC % 1440) + 1440) % 1440 + let start = wallClock(at, clock: 0, zone: zone) + let utc = calendar(TimeZone(identifier: "UTC")!) + var c = utc.dateComponents([.year, .month, .day], from: start) + c.hour = m / 60 + c.minute = m % 60 + var t = utc.date(from: c) ?? start + if t < start { t = t.addingTimeInterval(86_400) } + let l = calendar(zone).dateComponents([.hour, .minute], from: t) + return (l.hour ?? 0) * 60 + (l.minute ?? 0) + } + + /// The household clock as the box's API carries it: the UTC minute of + /// that time on `at`'s date. Core's `utcMinuteToday`. + public static func minuteUTC(clock: Int, at: Date, zone: TimeZone) -> Int { + let u = calendar(TimeZone(identifier: "UTC")!).dateComponents([.hour, .minute], from: wallClock(at, clock: clock, zone: zone)) + return (u.hour ?? 0) * 60 + (u.minute ?? 0) + } + + /// The ready time the box uses, on the household clock. + public static func goalClock(_ s: Loadpoint.Schedule, at: Date, zone: TimeZone) -> Int { + s.clockMin ?? clockFromUTC(s.timeOfDayMinUTC, at: at, zone: zone) + } + + /// The next time the household clock shows the ready time on an allowed + /// day, strictly after now: Core's `NextDeadlineUTC`. + public static func nextDeadlineMs(_ s: Loadpoint.Schedule, nowMs: Double, zone: TimeZone) -> Double { + let now = Date(timeIntervalSince1970: nowMs / 1000) + let clock = goalClock(s, at: now, zone: zone) + let cal = calendar(zone) + let today = cal.startOfDay(for: now) + for i in 0..<15 { + guard let day = cal.date(byAdding: .day, value: i, to: today) else { break } + let iso = (cal.component(.weekday, from: day) + 5) % 7 + if s.days != 0, s.days & (1 << iso) == 0 { continue } + let t = wallClock(day, clock: clock, zone: zone) + if t > now { return t.timeIntervalSince1970 * 1000 } + } + return nowMs + } + + /// "80 % by 07:00 on weekdays", on the household clock. + public static func goal(_ s: Loadpoint.Schedule, at: Date, zone: TimeZone) -> String { + let time = AppWords.clock(minute: goalClock(s, at: at, zone: zone)) + let when = dayPhrase(s.days, recurring: s.recurring) + if s.finishAtVehicleLimit { return AppWords.format("Charge to the car's limit by %1$@ %2$@", time, when) } + return AppWords.format("%1$@ by %2$@ %3$@", AppWords.percent(s.socPct), time, when) + } + + /// The deadline the box plans for: its own, or worked out the way it + /// works it out. Shown on the household clock, said so when the phone is + /// somewhere else. + public static func nextGoal(_ lp: Loadpoint, nowMs: Double, zone: TimeZone, phone: TimeZone = .current) -> String? { + guard let s = lp.schedule else { return nil } + let at = lp.targetTimeMs.flatMap { $0 > nowMs ? $0 : nil } ?? nextDeadlineMs(s, nowMs: nowMs, zone: zone) + let day = AppWords.day(at, nowMs: nowMs, zone: zone) + let away = zone.secondsFromGMT(for: Date(timeIntervalSince1970: nowMs / 1000)) != phone.secondsFromGMT(for: Date(timeIntervalSince1970: nowMs / 1000)) + return AppWords.format(away ? "Next: %@, home time" : "Next: %@", day) + } + + /// The goal VISION names, offered when there is none. + public static let suggestedGoal = (socPct: 80.0, clock: 7 * 60, days: 0b0011111) + + public static func suggestedGoalText() -> String { + AppWords.format("%1$@ by %2$@ %3$@", AppWords.percent(suggestedGoal.socPct), AppWords.clock(minute: suggestedGoal.clock), dayPhrase(suggestedGoal.days, recurring: true)) + } + + // MARK: The car's level + + /// Where the level on screen comes from, said as such. + public struct Level: Equatable, Sendable { + public enum Origin: Equatable, Sendable { + /// The car reported it within the last five minutes. + case car + /// The car reported it, longer ago than that. + case carEarlier + /// Someone set it in this app for this plug-in. + case you + /// The box's estimate from energy delivered. + case estimated + /// The box assumes it; nobody has said. + case assumed + /// The car stopped charging; the level is a guess pinned to the goal. + case unconfirmed + } + + public var pct: Double + public var origin: Origin + /// When the car reported it, or when it was set here. + public var atMs: Double? + /// What was set here, once the estimate has moved on from it. + public var setPct: Double? + } + + /// A level set in this app, kept for as long as the car stays plugged in. + public struct LevelSet: Equatable, Sendable { + public var pct: Double + public var atMs: Double + + public init(pct: Double, atMs: Double) { + self.pct = pct + self.atMs = atMs + } + } + + /// Core plans from a car's report for five minutes (VehicleMaxAge). + public static let carLevelMaxAgeMs: Double = 5 * 60_000 + + /// What the box assumes at plug-in when nothing is known. + public static let assumedPct: Double = 20 + + /// The level and where it comes from. Nil when no car is plugged in: + /// the box takes no level then, so none is offered. + public static func level(_ lp: Loadpoint, answeredAtMs: Double, nowMs: Double, set: LevelSet? = nil) -> Level? { + guard lp.pluggedIn else { return nil } + var car: (pct: Double, atMs: Double)? + if let pct = lp.vehicleSocPct { + car = (pct, answeredAtMs - max(0, lp.vehicleSocAgeS ?? 0) * 1000) + } else if lp.socSource == "vehicle", let pct = lp.socPct { + car = (pct, lp.socReadAtMs(answeredAtMs: answeredAtMs) ?? answeredAtMs) + } + if let car, set.map({ $0.atMs <= car.atMs }) ?? true { + let old = lp.vehicleStale || nowMs - car.atMs > carLevelMaxAgeMs + return Level(pct: car.pct, origin: old ? .carEarlier : .car, atMs: car.atMs, setPct: nil) + } + switch lp.socSource { + case "assumed": + return Level(pct: lp.socPct ?? assumedPct, origin: .assumed, atMs: nil, setPct: nil) + case "completed": + guard let pct = lp.socPct else { return nil } + return Level(pct: pct, origin: .unconfirmed, atMs: nil, setPct: nil) + default: + if let set { + let pct = lp.socPct ?? set.pct + return Level(pct: pct, origin: .you, atMs: set.atMs, setPct: abs(pct - set.pct) >= 1 ? set.pct : nil) + } + guard let pct = lp.socPct else { return nil } + return Level(pct: pct, origin: .estimated, atMs: nil, setPct: nil) + } + } + + /// The sentence under the level: who said it, and when if it matters. + public static func origin(_ l: Level, zone: TimeZone) -> String { + switch l.origin { + case .car: return AppWords.text("From the car") + case .carEarlier: return AppWords.format("From the car, last reported %@", l.atMs.map { AppWords.clock($0, zone: zone) } ?? "—") + case .you: + let at = l.atMs.map { AppWords.clock($0, zone: zone) } ?? "—" + if let set = l.setPct { return AppWords.format("You set %1$@ at %2$@. FTW adds what has charged since.", AppWords.percent(set), at) } + return AppWords.format("You set %1$@ at %2$@", AppWords.percent(l.pct), at) + case .estimated: return AppWords.text("FTW's estimate from energy delivered") + case .assumed: return AppWords.format("FTW assumes %@. Nobody has told it the real level.", AppWords.percent(l.pct)) + case .unconfirmed: return AppWords.text("The car stopped charging. Its level is not confirmed.") + } + } + + /// Two or three words for a row: "FTW assumes", "from the car". + public static func originShort(_ l: Level, zone: TimeZone) -> String { + switch l.origin { + case .car: return AppWords.text("from the car") + case .carEarlier: return AppWords.format("reported %@", l.atMs.map { AppWords.clock($0, zone: zone) } ?? "—") + case .you: return AppWords.text("you set") + case .estimated: return AppWords.text("estimated") + case .assumed: return AppWords.text("FTW assumes") + case .unconfirmed: return AppWords.text("not confirmed") + } + } + + /// The chips, and a step of five from wherever the level stands. + public static let levelPresets: [Double] = [20, 40, 60, 80] + + public static func stepped(_ pct: Double, up: Bool) -> Double { + let next = up ? (pct / 5).rounded(.down) * 5 + 5 : (pct / 5).rounded(.up) * 5 - 5 + return min(100, max(0, next)) + } + + // MARK: What the charger does + + /// What the charger is doing, in a few words. Power leads when there is any. + public static func status(_ lp: Loadpoint, zone: TimeZone = .current) -> String { + if let c = lp.charger, c.available != true { + return AppWords.text(c.known ? "Charger status is out of date." : "Waiting for the charger's first report.") + } + if !lp.pluggedIn { return AppWords.text("Not plugged in") } + if lp.manualActive { return manualStatus(lp) } + if lp.powerW >= 100 { return AppWords.format("Charging at %@", AppWords.power(lp.powerW)) } + if lp.chargingDeclined { return AppWords.text("The car stopped asking for power.") } + if lp.commandedKnown, lp.commandedReason == "site_meter_stale" { return AppWords.text("Paused for safety: the electricity meter is not reporting.") } + if lp.commandedKnown, lp.commandedW == 0, ["fuse_cooldown", "fuse_limit"].contains(lp.commandedReason) { return AppWords.text("Paused by the main fuse. It resumes on its own.") } + if !lp.planPending, !lp.planOutdated, let first = lp.planWindows.first ?? lp.planStartMs.map({ Loadpoint.Window(fromMs: $0, toMs: lp.planEndMs ?? $0, peakW: nil, energyWh: nil) }), first.fromMs > 0 { + return AppWords.format("Plugged in · charges at %@", AppWords.clock(first.fromMs, zone: zone)) + } + return AppWords.text("Plugged in, not charging 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 { + if isPaused(lp) { + if lp.manual?.state == "pausing" || (lp.manual?.state == "stalled" && lp.powerW >= 100) { + return AppWords.text("Pause sent. Waiting for the charger to stop.") + } + return AppWords.text("Paused by you") + } + if lp.powerW >= 100 { return AppWords.format("Charging now at %@", AppWords.power(lp.powerW)) } + switch lp.manual?.state { + case "unavailable": return AppWords.text("Charger status is out of date.") + case "accepted", "charging": return AppWords.text("The charger is starting…") + case "not_drawing": return AppWords.text("The car is not taking power. Check the car's charge limit.") + case "stalled": return AppWords.text("The charger has not responded. Check the charger.") + case "limited": + switch lp.manual?.limitReason { + case "charger_limit": return AppWords.text("The charger limits the current.") + case "site_meter_stale": return AppWords.text("Paused for safety: the electricity meter is not reporting.") + case "fuse_cooldown": return AppWords.text("Paused by the main fuse. It resumes on its own.") + default: return AppWords.text("The main fuse limits charging right now.") + } + default: return AppWords.text("Charge now sent to the charger") + } + } + + /// The plan for this car in one line, or why there is none. + public static func plan(_ lp: Loadpoint, windows: [Loadpoint.Window], nowMs: Double, zone: TimeZone) -> String? { + if lp.planPending { return AppWords.text("Updating the plan…") } + if lp.planOutdated { return AppWords.text("Charging times are unavailable. Your goal is saved.") } + guard lp.pluggedIn, !lp.manualActive, !lp.chargingDeclined, lp.charger?.available != false else { return nil } + if lp.gridDeferred, lp.schedule != nil { return AppWords.text("Waiting for tomorrow's prices. Spare solar can charge meanwhile.") } + let ahead = windows.filter { $0.toMs > nowMs } + guard let first = ahead.first else { + return lp.schedule == nil ? nil : AppWords.text("No charging planned yet.") + } + let wh = ahead.compactMap(\.energyWh).reduce(0, +) + let from = AppWords.clock(max(first.fromMs, nowMs), zone: zone) + let to = AppWords.clock(first.toMs, zone: zone) + let total = AppWords.energy(wh > 0 ? wh : (lp.planTotalWh ?? 0)) + if ahead.count > 1 { + return AppWords.format("Charging %1$@–%2$@, about %3$@, and %4$@ more times.", from, to, total, String(ahead.count - 1)) + } + return AppWords.format("Charging %1$@–%2$@, about %3$@.", from, to, total) + } + + public static func session(_ lp: Loadpoint) -> String? { + guard lp.pluggedIn, lp.sessionWh >= 50 else { return nil } + return AppWords.format("%@ this session", AppWords.energy(lp.sessionWh)) + } + + // MARK: Will the car be ready? + + public enum Readiness: Equatable, Sendable { + case reached + case onTrack(targetPct: Double, byMs: Double) + case short(reachPct: Double, targetPct: Double, byMs: Double) + } + + /// Within this much of the goal the plan counts as making it: a few + /// hundred watt-hours is rounding inside the plan, not a missed morning. + static func onTrackWh(_ capacityWh: Double) -> Double { max(300, capacityWh * 0.005) } + + /// Whether the car reaches its goal, from the plan's own shortfall for + /// it. Nil when the box has not said: no goal, a charge-now hold that + /// sets the plan aside, a plan being remade, or no shortfall read yet. + public static func readiness(_ lp: Loadpoint, level: Level?, shortfallWh: Double?, nowMs: Double, zone: TimeZone) -> Readiness? { + guard lp.pluggedIn, let s = lp.schedule, !lp.manualActive, !lp.planPending, !lp.planOutdated else { return nil } + if lp.goalComplete { return .reached } + guard let shortfallWh, shortfallWh.isFinite else { return nil } + let target = s.finishAtVehicleLimit ? 100 : s.socPct + let by = lp.targetTimeMs.flatMap { $0 > nowMs ? $0 : nil } ?? nextDeadlineMs(s, nowMs: nowMs, zone: zone) + if let pct = level?.pct, pct >= target { return .reached } + let capacity = (lp.vehicleCapacityWh ?? 0) > 0 ? lp.vehicleCapacityWh! : 60_000 + if shortfallWh <= onTrackWh(capacity) { return .onTrack(targetPct: target, byMs: by) } + let reach = max(level?.pct ?? 0, (target - shortfallWh / capacity * 100).rounded(.down)) + return .short(reachPct: reach, targetPct: target, byMs: by) + } + + public static func readiness(_ r: Readiness, level: Level?, nowMs: Double, zone: TimeZone) -> String { + let main: String + switch r { + case .reached: return AppWords.text("Goal reached.") + case .onTrack(let target, let by): + main = AppWords.format("On track: %1$@ by %2$@.", AppWords.percent(target), AppWords.day(by, nowMs: nowMs, zone: zone)) + case .short(let reach, let target, let by): + main = AppWords.format("Will not make it: about %1$@ by %2$@, not %3$@.", AppWords.percent(reach), AppWords.day(by, nowMs: nowMs, zone: zone), AppWords.percent(target)) + } + switch level?.origin { + case .assumed?, .unconfirmed?: return main + " " + AppWords.format("Counted from FTW's guess of %@.", AppWords.percent(level!.pct)) + default: return main + } + } + + // MARK: A command's outcome + + /// One command from the car view, followed from tap to outcome. + public struct Step: Equatable, Sendable { + public enum Ask: Equatable, Sendable { + case level(Double) + case chargeNow, pause, resume + } + + public enum Result: Equatable, Sendable { + /// The box did it, and read back what it now holds. + case applied(observed: Double?) + /// Something else changed it first; the box holds `observed`. + case superseded(observed: Double?) + /// Taken, not confirmed. Not a failure and not a success; the + /// note says more when the box did. + case unconfirmed(note: String?) + /// Refused, or never arrived. The reason is ready to show. + case refused(String) + } + + public var ask: Ask + /// The box said it has the intent (`cmd.ack`). + public var acked: Bool + /// What the box said happened (`cmd.result`), or why not. + public var result: Result? + + public init(ask: Ask, acked: Bool = false, result: Result? = nil) { + self.ask = ask + self.acked = acked + self.result = result + } + + public var settled: Bool { result != nil } + } + + /// The receipt under a control: three marks and one sentence. + public struct Outcome: Equatable, Sendable { + public enum Mark: Equatable, Sendable { case done, active, waiting, failed } + public enum Tone: Equatable, Sendable { case working, done, warning, failed } + + public var sent: Mark + public var received: Mark + public var done: Mark + public var text: String + public var tone: Tone + } + + public static func outcome(_ step: Step, _ lp: Loadpoint?) -> Outcome { + let received: Outcome.Mark + let done: Outcome.Mark + let tone: Outcome.Tone + switch step.result { + case nil: + received = step.acked ? .done : .active + done = step.acked ? .active : .waiting + tone = .working + case .applied?: + received = .done + done = .done + tone = .done + case .superseded?: + received = .done + done = .failed + tone = .warning + case .unconfirmed?: + received = .done + done = .waiting + tone = .warning + case .refused?: + received = step.acked ? .done : .failed + done = .failed + tone = .failed + } + return Outcome(sent: .done, received: received, done: done, text: outcomeText(step, lp), tone: tone) + } + + static func outcomeText(_ step: Step, _ lp: Loadpoint?) -> String { + if case .refused(let why)? = step.result { return why } + switch step.ask { + case .level(let asked): + let pct = AppWords.percent(asked) + switch step.result { + case nil: return AppWords.format(step.acked ? "FTW received %@." : "Sending %@ to FTW…", pct) + case .applied(let seen)?: return AppWords.format("FTW now plans from %@.", AppWords.percent(seen.map { $0 * 100 } ?? asked)) + case .superseded(let seen)?: return AppWords.format("FTW kept %@. A newer level came first.", seen.map { AppWords.percent($0 * 100) } ?? "—") + case .unconfirmed(let note)?: return note ?? AppWords.format("FTW received %@ but has not confirmed it yet.", pct) + case .refused?: return "" + } + case .chargeNow, .pause, .resume: + switch step.result { + case nil: + if step.acked { return AppWords.text("FTW received it.") } + switch step.ask { + case .pause: return AppWords.text("Sending Pause…") + case .resume: return AppWords.text("Sending Back to the plan…") + default: return AppWords.text("Sending Charge now…") + } + case .superseded?: return AppWords.text("Something else changed charging first. Try again.") + case .unconfirmed(let note)?: return note ?? AppWords.text("FTW received it but has not confirmed the result yet.") + case .applied?: + switch step.ask { + case .resume: return AppWords.text("Back to the plan. FTW decides when to charge.") + case .pause: + if let lp, lp.manualActive, lp.powerW >= 100 { return AppWords.text("Pause sent. Waiting for the charger to stop.") } + return AppWords.text("Paused. The car will not charge until you choose.") + default: + // The box holds the charge now; the charger's own account + // says whether the car takes it. + guard let lp, lp.manualActive, !isPaused(lp) else { return AppWords.text("FTW started charging. Waiting for the car to draw power.") } + if lp.powerW >= 100 { return AppWords.format("%@. The plan waits until you go back to it.", AppWords.format("Charging at %@", AppWords.power(lp.powerW))) } + switch lp.manual?.state { + case "not_drawing", "stalled", "limited", "unavailable": return manualStatus(lp) + default: return AppWords.text("FTW started charging. Waiting for the car to draw power.") + } + } + case .refused?: return "" + } + } + } + + /// Why a command did not happen, from the box's stable code. + public static func refusal(_ r: CmdResult) -> String { + if r.state == .expired { return AppWords.text("That took too long to reach FTW. Try again.") } + switch r.errorCode { + case "E_UNAVAILABLE": + switch r.errorArgs["reason"]?.string { + case "unplugged": return AppWords.text("Plug the car in first. FTW takes a level only while the car is plugged in.") + case "persistence_unconfirmed": return AppWords.text("FTW received it but could not save it for a restart.") + default: return AppWords.text("FTW cannot reach the charger right now. Try again shortly.") + } + case "E_PRECONDITION", "E_CONFLICT": return AppWords.text("Something changed at home while this was sending. Try again.") + case "E_SCOPE_DENIED", "E_GRANT_REVOKED": return AppWords.text("Only the home's owner can control charging.") + case "E_CMD_EXPIRED": return AppWords.text("That took too long to reach FTW. Try again.") + case "E_BOOTING": return AppWords.text("FTW is starting. Give it a minute.") + case "E_UNKNOWN_OP": return AppWords.text("Your box cannot do this yet. It may need an update.") + default: return AppWords.text("That did not go through. Try again.") + } + } + + /// Why a command never reached an answer. + public static func failure(_ error: Error) -> String { + if let e = error as? CommandError { + return e.code == "E_NO_ACK" ? AppWords.text("Did not reach FTW. Try again.") : AppWords.text("That did not go through. Try again.") + } + if error is SessionError { return AppWords.text("Not connected to FTW right now. Try again when the connection is back.") } + return AppWords.text("That did not go through. Try again.") + } + + // MARK: Charge now, in amps + + public struct Current: Equatable, Sendable { + public let minA: Int + public let maxA: Int + public let wattsPerAmp: Double + public let phases: Int + } + + /// The range, with the box page's fallbacks: three phases at 230 V, 6 to + /// 16 A when no floor or ceiling was reported. + public static func current(_ lp: Loadpoint) -> Current { + let phases = (lp.phases ?? 0) > 0 ? lp.phases! : 3 + let volts = (lp.voltageV ?? 0) > 0 ? lp.voltageV! : 230 + 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" + } +} diff --git a/appleApp/FTWKit/Sources/FTWKit/Format/Explanation.swift b/appleApp/FTWKit/Sources/FTWKit/Format/Explanation.swift new file mode 100644 index 0000000..ea5a6ef --- /dev/null +++ b/appleApp/FTWKit/Sources/FTWKit/Format/Explanation.swift @@ -0,0 +1,67 @@ +import Foundation +import FTWShared + +/// 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..3c8a933 --- /dev/null +++ b/appleApp/FTWKit/Sources/FTWKit/Format/Flow.swift @@ -0,0 +1,182 @@ +import Foundation +import FTWShared + +/// From a site's readings to the energy diagram's nodes: the same corners, +/// the same colours by role and direction, the same rules as the box's own +/// 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/Savings.swift b/appleApp/FTWKit/Sources/FTWKit/Format/Savings.swift new file mode 100644 index 0000000..8f6b446 --- /dev/null +++ b/appleApp/FTWKit/Sources/FTWKit/Format/Savings.swift @@ -0,0 +1,53 @@ +import Foundation +import FTWShared + +/// The box's compact savings card, from GET /api/savings/daily. +public enum Savings { + public struct Day: Equatable, Sendable { + public let day: String + public let savedOre: Double + public let resolution: String + } + + public struct Period: Equatable, Sendable { + public let savedMinor: Double + public let pricedDays: Int + public let totalDays: Int + public var available: Bool { pricedDays > 0 } + public var complete: Bool { totalDays > 0 && pricedDays == totalDays } + } + + public struct Periods: Equatable, Sendable { + public let today: Period + public let week: Period + public let month: Period + } + + public static func day(_ row: JSON) -> Day? { + guard let d = row["day"]?.string, d.count == 10, d.dropFirst(4).first == "-" else { return nil } + return Day(day: d, savedOre: row["saved_ore"]?.number ?? 0, resolution: row["resolution"]?.string ?? "slot") + } + + static func summarize(_ rows: [Day]) -> Period { + let priced = rows.filter { $0.resolution != "no_prices" } + return Period(savedMinor: priced.reduce(0) { $0 + $1.savedOre }, pricedDays: priced.count, totalDays: rows.count) + } + + public static func periods(_ days: [Day]) -> Periods { + let rows = days.sorted { $0.day < $1.day } + let month = rows.last.map { String($0.day.prefix(7)) } ?? "" + return Periods( + today: summarize(Array(rows.suffix(1))), + week: summarize(Array(rows.suffix(7))), + month: summarize(month.isEmpty ? [] : rows.filter { $0.day.hasPrefix(month + "-") }) + ) + } + + /// Signed major units: "+12.4", "−3.10". The currency sits in the heading. + public static func compact(_ minor: Double) -> String { + let major = minor / 100 + let a = abs(major) + let digits = a >= 100 ? 0 : a >= 10 ? 1 : 2 + return (major >= 0 ? "+" : "−") + PowerFormat.fixed(a, digits) + } +} diff --git a/appleApp/FTWKit/Sources/FTWKit/Identity/AboutWords.swift b/appleApp/FTWKit/Sources/FTWKit/Identity/AboutWords.swift new file mode 100644 index 0000000..d369896 --- /dev/null +++ b/appleApp/FTWKit/Sources/FTWKit/Identity/AboutWords.swift @@ -0,0 +1,93 @@ +import Foundation + +/// The words of Source & licenses and of removing this phone, in English or +/// Swedish as the phone prefers. Keyed by the English, so a missing entry +/// shows English rather than nothing; a test reads the views and fails on +/// any sentence without a Swedish entry. +public enum AboutWords { + public enum Language: String, Sendable { + case en, sv + + /// The phone's choice among the two the app speaks. + public static var preferred: Language { + Bundle.preferredLocalizations(from: ["en", "sv"]).first == "sv" ? .sv : .en + } + } + + @TaskLocal public static var language: Language = .preferred + + public static func text(_ en: String) -> String { + language == .sv ? (sv[en] ?? en) : en + } + + /// `%@` in order, replaced with ready text. + public static func format(_ en: String, _ args: String...) -> String { + var out = text(en) + for arg in args { + guard let r = out.range(of: "%@") else { break } + out.replaceSubrange(r, with: arg) + } + return out + } + + static let sv: [String: String] = [ + // Source & licenses + "Source & licenses": "Källkod och licenser", + "Done": "Klar", + "This version": "Den här versionen", + "Version": "Version", + "Build": "Bygge", + "Commit": "Commit", + "Local build": "Lokalt bygge", + "Source code": "Källkod", + "Source code of this version": "Källkoden till den här versionen", + "Source code on GitHub": "Källkoden på GitHub", + "License at this commit": "Licensen i den här commiten", + "License on GitHub": "Licensen på GitHub", + "FTW is free software. You may run, study, change and share it under the GNU AGPL v3 with the two additional permissions in LICENSE. The link gives the complete source of this exact version, at no charge.": + "FTW är fri programvara. Du får köra, studera, ändra och dela den enligt GNU AGPL v3 med de två extra tillstånden i LICENSE. Länken ger hela källkoden till exakt den här versionen, utan kostnad.", + "Built from uncommitted changes on top of %@. The links go to the repository, which does not hold these changes.": + "Byggd från ändringar som inte är incheckade, ovanpå %@. Länkarna går till repot, som inte har de här ändringarna.", + "Built without a known commit. The links go to the repository, which may not hold this exact code.": + "Byggd utan känd commit. Länkarna går till repot, som kanske inte har exakt den här koden.", + "Licenses": "Licenser", + "License": "Licens", + "Notices": "Notiser", + "Licensing in plain words": "Licensen i klartext", + "Third-party code": "Tredjepartskod", + "None. FTW uses only Apple's own frameworks.": "Ingen. FTW använder bara Apples egna ramverk.", + "© 2026 Sourceful Labs AB and contributors.": "© 2026 Sourceful Labs AB och bidragsgivare.", + "There is no warranty, to the extent the law allows.": "Det finns ingen garanti, så långt lagen tillåter.", + "This text is missing from this build.": "Texten saknas i det här bygget.", + "Privacy": "Integritet", + "Privacy policy": "Integritetspolicy", + "Support": "Support", + "Privacy and support": "Integritet och support", + "FTW collects nothing about you. Readings and commands travel encrypted between this phone and your box, and Sourceful cannot read them.": + "FTW samlar inte in något om dig. Mätvärden och kommandon går krypterade mellan telefonen och boxen, och Sourceful kan inte läsa dem.", + + // Removing this phone + "This phone": "Den här telefonen", + "Remove this phone": "Ta bort den här telefonen", + "This deletes what FTW keeps on this phone: the key to your home, the pairing, the saved readings and what the widgets show. Your box and everything on it stay as they are.": + "Det här raderar det FTW sparar på telefonen: nyckeln till ditt hem, parkopplingen, sparade mätvärden och det widgetarna visar. Boxen och allt på den blir kvar som det är.", + "FTW has no account with Sourceful. Your box keeps a row for this phone until you remove it there.": + "FTW har inget konto hos Sourceful. Boxen har kvar en rad för telefonen tills du tar bort den där.", + "On your box": "På boxen", + "Your box calls this phone": "Boxen kallar telefonen", + "Open your box's local dashboard while on your home network.": "Öppna boxens lokala sida medan du är på hemnätverket.", + "Go to Settings → FTW app.": "Gå till Settings → FTW app.", + "Choose Remove next to %@.": "Välj Remove bredvid %@.", + "Once it is gone there, the box no longer lets this phone in. Do it before or after the step below.": + "När den är borta där släpper boxen inte längre in telefonen. Gör det före eller efter steget nedan.", + "On this phone": "På telefonen", + "Remove from this phone": "Ta bort från telefonen", + "If you made a passkey for FTW, it stays in the Passwords app, where you can delete it. Keep it if another device of yours still uses FTW.": + "Om du skapade en lösennyckel för FTW finns den kvar i appen Lösenord, där du kan radera den. Behåll den om en annan av dina enheter fortfarande använder FTW.", + "Remove this phone?": "Ta bort den här telefonen?", + "FTW forgets your home on this phone. To come back, scan a new pairing QR from your box.": + "FTW glömmer ditt hem på telefonen. För att komma tillbaka skannar du en ny QR-kod för parkoppling från boxen.", + "Remove": "Ta bort", + "Cancel": "Avbryt", + ] +} diff --git a/appleApp/FTWKit/Sources/FTWKit/Identity/BuildSource.swift b/appleApp/FTWKit/Sources/FTWKit/Identity/BuildSource.swift new file mode 100644 index 0000000..d50adcf --- /dev/null +++ b/appleApp/FTWKit/Sources/FTWKit/Identity/BuildSource.swift @@ -0,0 +1,93 @@ +import Foundation + +/// What this build was made from, and where its source is. +/// +/// The AGPL and the app store permission in LICENSE ask that the license and +/// an offer of the exact version's source be reachable in the app. The build +/// writes its commit into the bundle (the "Record the source revision" phase +/// in the Xcode project), and this turns that record into the links and the +/// words Source & licenses shows. +public struct BuildSource: Equatable, Sendable { + public enum Revision: Equatable, Sendable { + /// A clean tree at a known commit. The links pin it. + case commit(String) + /// Uncommitted changes on top of `base`, or no commit known at all. + /// The links go to the repository, which may not hold this code. + case local(base: String?) + } + + public let version: String + public let build: String + public let revision: Revision + + /// What the build phase wrote: 40 hex digits for a clean tree, the same + /// with `-dirty` for uncommitted changes, and nothing when no commit was + /// known. Anything else is a local build too. + public init(version: String, build: String, recorded: String?) { + self.version = version + self.build = build + let text = (recorded ?? "").trimmingCharacters(in: .whitespacesAndNewlines) + if Self.isCommit(text) { + revision = .commit(text) + } else if text.hasSuffix(Self.dirtySuffix), Self.isCommit(String(text.dropLast(Self.dirtySuffix.count))) { + revision = .local(base: String(text.dropLast(Self.dirtySuffix.count))) + } else { + revision = .local(base: nil) + } + } + + static let dirtySuffix = "-dirty" + + static func isCommit(_ text: String) -> Bool { + text.count == 40 && text.allSatisfy { $0.isHexDigit && !$0.isUppercase } + } + + public var commit: String? { + if case .commit(let sha) = revision { return sha } + return nil + } + + public var isLocal: Bool { commit == nil } + + /// Seven characters, as GitHub shows a commit. + public static func short(_ sha: String) -> String { String(sha.prefix(7)) } + + /// The tree at this commit, whose Code menu offers the archive. A local + /// build gets the repository. + public var sourceURL: URL { + guard let commit else { return Origin.sourceURL } + return Origin.sourceURL.appending(path: "tree/\(commit)") + } + + /// The license as it stood at this commit, not on the moving branch. + public var licenseURL: URL { + Origin.sourceURL.appending(path: "blob/\(commit ?? "main")/LICENSE") + } + + /// Where a relative link in LICENSING.md points, at the same revision. + public var documentBaseURL: URL { + Origin.sourceURL.appending(path: "blob/\(commit ?? "main")/") + } + + /// "0.1.0 (1)", as Settings shows an app's version. + public var versionText: String { "\(version) (\(build))" } + + /// The commit as the screen names it. + public var revisionText: String { + switch revision { + case .commit(let sha): return Self.short(sha) + case .local: return AboutWords.text("Local build") + } + } + + /// Why the links do not pin this build, or nil when they do. + public var localNote: String? { + switch revision { + case .commit: return nil + case .local(let base?): + return AboutWords.format("Built from uncommitted changes on top of %@. The links go to the repository, which does not hold these changes.", Self.short(base)) + case .local(nil): + return AboutWords.text("Built without a known commit. The links go to the repository, which may not hold this exact code.") + } + } +} diff --git a/appleApp/FTWKit/Sources/FTWKit/Identity/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/LicenseText.swift b/appleApp/FTWKit/Sources/FTWKit/Identity/LicenseText.swift new file mode 100644 index 0000000..39fda9a --- /dev/null +++ b/appleApp/FTWKit/Sources/FTWKit/Identity/LicenseText.swift @@ -0,0 +1,155 @@ +import Foundation + +/// The license files as a phone reads them. +/// +/// The files are wrapped at about 72 columns for a terminal. Shown as they +/// are, every line breaks twice on a phone: once where the file breaks it and +/// once where the screen does. This joins the lines of a paragraph and keeps +/// every word in order; it changes line breaks, never text. +public enum LicenseText { + /// The three files the build copies into the app from the repository + /// root. Nothing here is a copy that could drift from the original. + public enum Document: String, CaseIterable, Sendable, Identifiable { + case license = "LICENSE" + case notice = "NOTICE" + case licensing = "LICENSING.md" + + public var id: String { rawValue } + + public var isMarkdown: Bool { self == .licensing } + + public var title: String { + switch self { + case .license: return AboutWords.text("License") + case .notice: return AboutWords.text("Notices") + case .licensing: return AboutWords.text("Licensing in plain words") + } + } + } + + public struct Block: Equatable, Sendable, Identifiable { + public enum Kind: Equatable, Sendable { + /// 1 is a document's title, 2 a section. + case heading(Int) + case paragraph + /// A line of dashes or equals signs between parts. + case rule + } + + public let id: Int + public let kind: Kind + /// Lines that start a list item or end short in the file keep their + /// break as "\n". Every other break is a space. + public let text: String + } + + public static func blocks(_ text: String, markdown: Bool) -> [Block] { + let lines = text.replacingOccurrences(of: "\r\n", with: "\n").components(separatedBy: "\n") + // The column the file wraps at, so a line that ends well short of it + // is known to end there on purpose. + let width = min(80, lines.map(\.count).max() ?? 0) + var out: [Block] = [] + var paragraph: [String] = [] + + func add(_ kind: Block.Kind, _ text: String) { + out.append(Block(id: out.count, kind: kind, text: text)) + } + + func flush() { + guard !paragraph.isEmpty else { return } + defer { paragraph = [] } + if !markdown, paragraph.count == 1, let level = plainHeading(paragraph[0]) { + add(.heading(level), paragraph[0].trimmingCharacters(in: .whitespaces)) + return + } + // A title block: lines set in the middle of the page, or a + // name in capitals over a short line, such as the license's + // name over its version. + let titled = paragraph.count == 2 && plainHeading(paragraph[0]) == 1 && paragraph[1].trimmingCharacters(in: .whitespaces).count <= 40 + if !markdown, paragraph.count > 1, titled || paragraph.allSatisfy(isCentred) { + add(.heading(1), paragraph.map { $0.trimmingCharacters(in: .whitespaces) }.joined(separator: "\n")) + return + } + add(.paragraph, join(paragraph, width: width)) + } + + for line in lines { + let trimmed = line.trimmingCharacters(in: .whitespaces) + if trimmed.isEmpty { + flush() + } else if isRule(trimmed) { + flush() + add(.rule, "") + } else if markdown, let (level, title) = markdownHeading(trimmed) { + flush() + add(.heading(level), title) + } else { + paragraph.append(line) + } + } + flush() + return out + } + + /// A Markdown paragraph with its links made absolute against `base`, so + /// LICENSING.md's `[LICENSE](LICENSE)` opens the file at the build's + /// commit rather than nowhere. Plain text when it does not parse. + public static func inline(_ text: String, base: URL) -> AttributedString { + guard var out = try? AttributedString(markdown: text, options: .init(interpretedSyntax: .inlineOnlyPreservingWhitespace), baseURL: base) else { + return AttributedString(text) + } + for run in out.runs { + if let link = run.link { + out[run.range].link = link.absoluteURL + } + } + return out + } + + /// Joins a paragraph's lines. A line keeps its break when the next one + /// starts a list item, or when it ends well short of the wrap column, + /// as an address or a title over a line does. + static func join(_ lines: [String], width: Int) -> String { + var out = "" + for (i, raw) in lines.enumerated() { + let line = raw.trimmingCharacters(in: .whitespaces) + if i > 0 { + let short = lines[i - 1].count < width - 20 + out += startsItem(line) || short ? "\n" : " " + } + out += line + } + return out + } + + static func startsItem(_ line: String) -> Bool { + line.hasPrefix("- ") || line.hasPrefix("* ") || line.hasPrefix("• ") + || line.range(of: #"^(\(?[a-z0-9]{1,3}\)|[0-9]{1,2}\.) "#, options: .regularExpression) != nil + } + + static func isRule(_ line: String) -> Bool { + line.count >= 3 && Set(line).count == 1 && (line.first == "-" || line.first == "=" || line.first == "_") + } + + static func markdownHeading(_ line: String) -> (Int, String)? { + let hashes = line.prefix(while: { $0 == "#" }).count + guard (1...6).contains(hashes), line.dropFirst(hashes).first == " " else { return nil } + return (min(hashes, 2), String(line.dropFirst(hashes + 1))) + } + + static func isCentred(_ line: String) -> Bool { + line.prefix(while: { $0 == " " }).count >= 8 && line.trimmingCharacters(in: .whitespaces).count <= 60 + } + + /// In plain text a title is a line on its own: set in capitals, centred + /// with spaces, or a numbered section such as "0. Definitions." + static func plainHeading(_ line: String) -> Int? { + let trimmed = line.trimmingCharacters(in: .whitespaces) + guard trimmed.count <= 60 else { return nil } + let letters = trimmed.filter(\.isLetter) + if !letters.isEmpty, letters == letters.uppercased(), letters.count >= 4 { return 1 } + if isCentred(line) { return 1 } + if trimmed.range(of: #"^[0-9]{1,2}\. [A-Z][^.]*\.$"#, options: .regularExpression) != nil { return 2 } + return nil + } +} diff --git a/appleApp/FTWKit/Sources/FTWKit/Identity/Origin.swift b/appleApp/FTWKit/Sources/FTWKit/Identity/Origin.swift new file mode 100644 index 0000000..a18dcb7 --- /dev/null +++ b/appleApp/FTWKit/Sources/FTWKit/Identity/Origin.swift @@ -0,0 +1,36 @@ +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 repository. A fork changes this line, as LICENSING.md asks: the + /// links in Source & licenses must lead to the fork's own source. + /// `BuildSource` pins them to the build's commit. + public static let sourceURL = URL(string: "https://github.com/srcfl/ftw-app")! + + /// The privacy policy, which App Review asks to be easy to find in the + /// app, and the support page beside it. Swedish where the app speaks it. + public static func privacyURL(_ language: AboutWords.Language = AboutWords.language) -> URL { + site(language, "privacy") + } + + public static func supportURL(_ language: AboutWords.Language = AboutWords.language) -> URL { + site(language, "support") + } + + private static func site(_ language: AboutWords.Language, _ page: String) -> URL { + URL(string: "https://ftw.sourceful.energy/\(language == .sv ? "sv/" : "")\(page)")! + } +} diff --git a/appleApp/FTWKit/Sources/FTWKit/Identity/PRF.swift b/appleApp/FTWKit/Sources/FTWKit/Identity/PRF.swift new file mode 100644 index 0000000..ba22d1c --- /dev/null +++ b/appleApp/FTWKit/Sources/FTWKit/Identity/PRF.swift @@ -0,0 +1,73 @@ +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 +/// wraps a device key in the web app unwraps it in this app, and the +/// reverse. +public enum PRF { + /// One fixed salt, forever. The authenticator is only ever asked for it. + public static let vaultSalt = Array("ftw.prf.v1.vault".utf8) + + static let wrapInfo = Array("ftw.wrap.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) + ) + } +} + +/// 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 +} + +/// 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..eed537d --- /dev/null +++ b/appleApp/FTWKit/Sources/FTWKit/Identity/Pairing.swift @@ -0,0 +1,53 @@ +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. +@MainActor +public final class Pairing { + private let vault: Vault + private let sites: SiteList + private let passkeys: PasskeyAuthenticator? + private let now: () -> Double + + public init(vault: Vault, sites: SiteList, passkeys: PasskeyAuthenticator?, now: @escaping () -> Double = { Date().timeIntervalSince1970 * 1000 }) { + self.vault = vault + self.sites = sites + self.passkeys = passkeys + self.now = now + } + + /// Throws `EnrollmentError` with a sentence, or `PasskeyCancelled`. + public func pair(scanned: String) async throws -> StoredSite { + 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, + addedAtMs: at, + lastSeenAtMs: at + ) + sites.put(site) + return site + } +} diff --git a/appleApp/FTWKit/Sources/FTWKit/Identity/PairingImage.swift b/appleApp/FTWKit/Sources/FTWKit/Identity/PairingImage.swift new file mode 100644 index 0000000..997df3d --- /dev/null +++ b/appleApp/FTWKit/Sources/FTWKit/Identity/PairingImage.swift @@ -0,0 +1,20 @@ +import CoreImage +import Foundation + +/// A pairing link read off a picture of the QR. The simulator has no camera, +/// so this is how pairing is tried there. The link then takes the same path +/// as one the camera read. +public enum PairingImage { + /// The first QR in the picture that `Enrollment` accepts, or nil. + public static func link(in imageData: Data) -> String? { + guard let image = CIImage(data: imageData), + let detector = CIDetector( + ofType: CIDetectorTypeQRCode, + context: CIContext(options: [.useSoftwareRenderer: true]), + options: [CIDetectorAccuracy: CIDetectorAccuracyHigh] + ) else { return nil } + return detector.features(in: image) + .compactMap { ($0 as? CIQRCodeFeature)?.messageString } + .first { (try? Enrollment.parse(scanned: $0)) != nil } + } +} diff --git a/appleApp/FTWKit/Sources/FTWKit/Identity/PhoneRemoval.swift b/appleApp/FTWKit/Sources/FTWKit/Identity/PhoneRemoval.swift new file mode 100644 index 0000000..de7b472 --- /dev/null +++ b/appleApp/FTWKit/Sources/FTWKit/Identity/PhoneRemoval.swift @@ -0,0 +1,27 @@ +import Foundation + +/// Removing this phone, as far as the phone can reach. +/// +/// The registry has no op that lets a phone take itself off its box, so the +/// app does what is in its own hands, `AppModel.leave`: the key, the pairing, +/// the sealed readings and the widgets' records go. It then says how the +/// owner removes the phone on the box. That is also the one way that always +/// works: over a session the box refuses to remove its last owner, and on +/// its own page it allows it. +/// +/// FTW creates no account. A pairing is a key on this phone and a row on the +/// household's own box, and this is where both are dealt with. +public enum PhoneRemoval { + /// The row on the box's Settings → FTW app page, which names each phone + /// by the first eight characters of its key (`Vault.deviceIDOnBox`). + /// The box's page is in English, so the name is too. + public static func nameOnBox(_ deviceID: String) -> String { "Phone \(deviceID)" } + + public static func boxSteps(_ deviceID: String) -> [String] { + [ + AboutWords.text("Open your box's local dashboard while on your home network."), + AboutWords.text("Go to Settings → FTW app."), + AboutWords.format("Choose Remove next to %@.", nameOnBox(deviceID)), + ] + } +} diff --git a/appleApp/FTWKit/Sources/FTWKit/Identity/Vault.swift b/appleApp/FTWKit/Sources/FTWKit/Identity/Vault.swift new file mode 100644 index 0000000..1f9a91d --- /dev/null +++ b/appleApp/FTWKit/Sources/FTWKit/Identity/Vault.swift @@ -0,0 +1,317 @@ +import Foundation + +/// The device key and the copies that unlock it. +/// +/// device key X25519 static for Noise, stored AES-GCM wrapped once per +/// credential. +/// wrapping key from passkey PRF, at enrollment and before privileged +/// acts. Never stored. +/// local key random, in the Keychain, no prompt in front of it. It is +/// what lets reading the house cost no Face ID. +/// +/// The local copy is the cache key's claim and no more: it resists an +/// offline read of the disk, not someone holding the unlocked phone, who can +/// simply open the app and look. PRF still guards enrollment and the writes +/// that need a step-up. +/// +/// The sealed plaintext is PKCS#8, the same bytes the web app seals, so a +/// record either one writes opens in the other given the same passkey. +@MainActor +public final class Vault { + public static let localCredentialID = "local" + /// The box names a phone by this many characters of its key. + public static let boxDeviceIDChars = 8 + + static let vaultKey = "device-key" + static let localWrapKey = "local-wrap-key" + static let userHandleKey = "user-handle" + + /// DER header of a PKCS#8 X25519 private key; the scalar is the last 32. + nonisolated static let pkcs8Prefix: Bytes = [0x30, 0x2e, 0x02, 0x01, 0x00, 0x30, 0x05, 0x06, 0x03, 0x2b, 0x65, 0x6e, 0x04, 0x22, 0x04, 0x20] + + public struct VaultError: Error, Equatable, HelpfulError { + public let code: String + public let message: String + public let help: String + } + + struct Copy: Codable, Equatable { + var credentialId: String + var source: WrappingSource + var iv: Data + var ct: Data + } + + struct Record: Codable, Equatable { + var publicKey: Data + var copies: [Copy] + } + + private let store: SecureStore + + public init(store: SecureStore) { + self.store = store + } + + // MARK: Reads that never prompt + + public var isEnrolled: Bool { record != nil } + + public var devicePublic: Bytes? { record.map { $0.publicKey.byteArray } } + + /// The name this phone answers to on the box's list of paired phones, + /// worked out locally so it is here exactly when the box is not. + public var deviceIDOnBox: String? { + devicePublic.map { String(Base64url.encode($0).prefix(Self.boxDeviceIDChars)) } + } + + public var credentialIDs: [String] { record?.copies.map(\.credentialId) ?? [] } + + /// Only the copies a passkey stands behind. The local copy is not one. + public var passkeyCredentialIDs: [String] { + record?.copies.filter { $0.source == .prf }.map(\.credentialId) ?? [] + } + + // MARK: The device key + + /// The device key, created on first use. It is this phone's identity, so + /// pairing a second box reuses it rather than orphaning the first. + public func deviceKey(_ wrapping: WrappingKey) throws -> 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) + } + let key = randomBytes(32) + store.put(Self.localWrapKey, key) + return WrappingKey(credentialID: Self.localCredentialID, source: .local, key: key) + } + + /// 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..6774f90 --- /dev/null +++ b/appleApp/FTWKit/Sources/FTWKit/Protocol/Contract.swift @@ -0,0 +1,123 @@ +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 opPlannerPrefsSet = "planner.prefs.set" + public static let opBatteryHold = "battery.hold" + public static let opLoadpointHold = "loadpoint.hold" + public static let opLoadpointBoost = "loadpoint.boost" + public static let opLoadpointSocSet = "loadpoint.soc.set" + public static let opLoadpointSurplusOnlySet = "loadpoint.surplus_only.set" + + public static let ops = [opSetMode, opPlannerPrefsSet, opBatteryHold, opLoadpointHold, opLoadpointBoost, opLoadpointSocSet, opLoadpointSurplusOnlySet] + + /// The box's dispatch modes and where each is drawn. The box sends its + /// own catalogue and is the authority on what exists; this copy is for + /// validating and wording, never for deciding. + public static let modes: [(key: String, tier: String)] = [ + ("planner_passive_arbitrage", "primary"), + ("planner_arbitrage", "primary"), + ("idle", "advanced"), + ("self_consumption", "advanced"), + ("peak_shaving", "advanced"), + ("charge", "advanced"), + ("planner_self", "hidden"), + ("planner_cheap", "hidden"), + ] + + /// Field ids frozen permanently as of v1. + public enum FID { + 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/Messages.swift b/appleApp/FTWKit/Sources/FTWKit/Protocol/Messages.swift new file mode 100644 index 0000000..0636938 --- /dev/null +++ b/appleApp/FTWKit/Sources/FTWKit/Protocol/Messages.swift @@ -0,0 +1,336 @@ +import Foundation +import FTWShared + +/// 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 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: - Prices + +extension Prices { + init(_ c: CBOR) { + self.init( + zone: c["zone"]?.string ?? "", + currency: c["currency"]?.string ?? "SEK", + slots: (c["slots"]?.array ?? []).compactMap { s in + guard let start = s["startMs"]?.double else { return nil } + return PriceSlot( + startMs: start, + durationMs: s["durationMs"]?.double ?? 3_600_000, + spotMinor: s["spotMinor"]?.double ?? 0, + totalMinor: s["totalMinor"]?.double ?? s["spotMinor"]?.double ?? 0 + ) + }, + stale: c["stale"]?.bool ?? false + ) + } +} + +// 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..63b9edf --- /dev/null +++ b/appleApp/FTWKit/Sources/FTWKit/Protocol/Session.swift @@ -0,0 +1,695 @@ +import Foundation +import FTWShared + +/// 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 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 + /// The relay turned this phone away: the home already has as many + /// phones watching as it serves. Someone else is looking; nothing broke. + public var busy = false + /// The last delta could not carry every changed field. Not a fault. + public var truncated = false + /// Every mode the box accepts, in its order. Field 1 indexes this list. + 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 { + public static let apiTimeoutMs: Double = 20_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 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 + var onAck: (@MainActor () -> Void)? + } + + 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 + s.busy = false + 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 + } + + /// 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. `onAck` runs when the box takes the intent, + /// so a screen can say so before the result. + public func command(_ op: String, args: [(String, CBOR)], guards: [Guard] = [], onAck: (@MainActor () -> Void)? = nil) async throws -> CmdResult { + guard let carrier else { throw SessionError.noCarrier } + let cmdId = Self.uuidv7(nowMs: scheduler.nowMs) + let body = CBOR.map([ + ("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, onAck: onAck) + 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 + s.busy = false + 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 + s.busy = status.isBusy + 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 "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() + let first = !p.acked + p.acked = true + pendingCommands[cmdId] = p + if first { p.onAck?() } + case "cmd.result": + let result = CmdResult(body) + guard let p = pendingCommands.removeValue(forKey: result.cmdId) else { return } + 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 = 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 + + 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 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..06f0bf9 --- /dev/null +++ b/appleApp/FTWKit/Sources/FTWKit/State/AppModel.swift @@ -0,0 +1,288 @@ +import Foundation +import FTWShared +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 prices: PriceModel + public let status: StatusWatch + public let charging: ChargingWatch + public let savings: SavingsModel + public let loadpoints: LoadpointsModel + + init(site: SiteModel) { + self.site = site + prices = PriceModel(site: site) + status = StatusWatch(site: site) + charging = ChargingWatch(site: site) + savings = SavingsModel(site: site) + loadpoints = LoadpointsModel(site: site, charging: charging) + } +} + +/// Builds the carrier to a simulated home. FTWDemo supplies it; FTWKit holds +/// no fake box of its own. +public typealias DemoCarrier = @MainActor (Scheduler) -> Carrier + +/// No carrier could be built from what this phone holds. Every other +/// failure heals itself because the carrier reconnects from inside; these +/// are the ones with nothing to reconnect, so the screen offers a way back. +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 + + @ObservationIgnored public let vault: Vault + @ObservationIgnored public let sites: SiteList + @ObservationIgnored public let pairing: Pairing + @ObservationIgnored public let passkeys: PasskeyAuthenticator? + @ObservationIgnored let store: SecureStore + @ObservationIgnored let files: SealedFiles? + @ObservationIgnored let shared: AppGroupFiles? + @ObservationIgnored let sharedWritten: (@MainActor () -> Void)? + @ObservationIgnored let demo: DemoCarrier? + @ObservationIgnored let scheduler: Scheduler + @ObservationIgnored let build: String + @ObservationIgnored let ua: String + @ObservationIgnored let relayURL: URL + @ObservationIgnored let makeSocket: WebSocketFactory + @ObservationIgnored private var connectGeneration = 0 + /// `suspend` closed the session; the next look builds a new one. + @ObservationIgnored private var suspended = false + + public init( + store: SecureStore, + files: SealedFiles?, + shared: AppGroupFiles? = nil, + sharedWritten: (@MainActor () -> Void)? = nil, + passkeys: PasskeyAuthenticator?, + scheduler: Scheduler = LiveScheduler.shared, + build: String, + ua: String, + relayURL: URL = Origin.relayURL, + makeSocket: @escaping WebSocketFactory = URLSessionWebSocket.factory(), + demo: DemoCarrier? = nil + ) { + self.store = store + self.files = files + self.shared = shared + self.sharedWritten = sharedWritten + self.demo = demo + self.passkeys = passkeys + self.scheduler = scheduler + self.build = build + self.ua = ua + self.relayURL = relayURL + self.makeSocket = makeSocket + vault = Vault(store: store) + sites = SiteList(store: store) + pairing = Pairing(vault: vault, sites: sites, 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, shared: shared) + site.stepUp = { [weak self] in await self?.stepUp() ?? .unavailable } + site.sharedWritten = sharedWritten + home = HomeModels(site: site) + sites.setCurrent(siteId) + site.start() + recovering = false + suspended = 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 { + site?.refresh() + return + } + if connectHelp != nil || site?.core.hasCarrier == false { + await connect() + } else { + site?.refresh() + } + } + + /// Whether anyone is looking. Coming back from the background builds a + /// new carrier, because `suspend` closed the old one. + public func setVisible(_ visible: Bool) { + guard let site else { return } + site.setVisible(visible) + if visible, suspended { + suspended = false + Task { await connect() } + } + } + + /// The app went to the background: close the session and keep what is + /// on screen. The demo has no socket to free and keeps running. + public func suspend() { + guard let site else { return } + if isDemo { + site.setVisible(false) + } else { + site.suspend() + suspended = true + } + } + + public func networkOnline() { + 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 + + /// Whether this build carries the simulated home. + public var canDemo: Bool { demo != nil } + + /// A simulated home in this process. Nothing is written and no passkey + /// is asked for; leaving it touches no saved home. + public func startDemo() { + guard let demo else { return } + home?.site.destroy() + let site = SiteModel(siteId: nil, build: build, ua: ua, scheduler: scheduler, files: nil, isDemo: true) + site.ceilingW = 11_000 + home = HomeModels(site: site) + isDemo = true + recovering = false + offeredLink = nil + connectHelp = nil + site.start() + site.connect(demo(scheduler)) + } + + public func exitDemo() { + guard isDemo else { return } + home?.site.destroy() + home = 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 widgets lose their records too. + public func leave() { + home?.site.destroy() + home = nil + vault.reset() + sites.clear() + files?.clear() + shared?.removeAll() + sharedWritten?() + 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..be6c6bd --- /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: AppWords.text("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: AppWords.text("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 AppWords.text("Your box doesn't have that yet — it may be running older software.") + case "E_UNAVAILABLE": + return AppWords.text(args["reason"]?.string == "busy" ? "Your box is busy with something else. This will fill in shortly." : "Your box can't answer that right now. Still trying.") + case "E_SCOPE_DENIED": return AppWords.text("Only the owner of this home can change that.") + case "E_GRANT_REVOKED": return AppWords.text("Your access to this home was withdrawn by its owner.") + case "E_USE_CMD": return AppWords.text("That has to be done from the controls on the home screen.") + case "E_UNSUPPORTED_MEDIA", "E_WHOLE_DOCUMENT", "E_LOCAL_ONLY": return AppWords.text("That one is only available on your box's own page, from home.") + case "E_RESPONSE_TOO_LARGE": return AppWords.text("That's more than we can send over your connection — narrow the range.") + case "E_NEEDS_STEP_UP": return AppWords.text("Your box needs more proof than this phone can give. Do it on your box.") + default: return AppWords.text("That didn't work. Nothing on your box has changed.") + } + } + + /// What happens now, for a status a handler answered with. + public static func statusHelp(_ status: Int, _ code: String?) -> String { + if code == "E_LAST_OWNER_PROTECTED" { return AppWords.text("Someone has to own this home, so the last owner cannot be removed.") } + switch status { + case 404: return AppWords.text("That's no longer on your box.") + case 403: return AppWords.text("Your box refused that from this phone.") + case 409: return AppWords.text("Something changed on your box first. Nothing here was applied.") + case 503: return AppWords.text("Your box can't answer that yet. Still trying.") + case 500...: return AppWords.text("Something went wrong on your box. Nothing has changed.") + default: return AppWords.text("Your box couldn't do that.") + } + } +} + +/// 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/LoadpointsModel.swift b/appleApp/FTWKit/Sources/FTWKit/State/LoadpointsModel.swift new file mode 100644 index 0000000..8633717 --- /dev/null +++ b/appleApp/FTWKit/Sources/FTWKit/State/LoadpointsModel.swift @@ -0,0 +1,458 @@ +import Foundation +import FTWShared +import Observation + +/// The car view. Everything on it is a fact the box served; the controls +/// express intent and the box decides. Each command is followed from the +/// tap to the box's answer and stays on screen, and after any outcome the +/// charger is read again, because the box's account is the truth to +/// repaint from. +/// +/// No timer asks the box. The chargers are read when a screen that shows +/// them appears, after a command, when the stream says the car started or +/// stopped drawing power, and when the stream comes back. Until the box +/// streams charger state (M5), that is all. +@Observable +@MainActor +public final class LoadpointsModel: Activatable { + public enum GoalSave: Equatable, Sendable { + case saving + case saved + case removed + case failed(String) + } + + public private(set) var points: [Loadpoint] = [] + public private(set) var windows: [String: [Loadpoint.Window]] = [:] + public private(set) var planMissing = false + public private(set) var 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? + /// The plan's shortfall per car, in battery watt-hours, read while the + /// car view is open. Missing means the plan has no goal for that car. + public private(set) var shortfallWh: [String: Double] = [:] + public private(set) var shortfallReadAtMs: Double? + /// The last level and the last charge command per charger, from the + /// tap to the box's answer. + public private(set) var levelStep: [String: EVText.Step] = [:] + public private(set) var holdStep: [String: EVText.Step] = [:] + public private(set) var goalSave: [String: GoalSave] = [:] + /// A level tapped and not yet answered: what the view shows meanwhile. + public private(set) var levelWanted: [String: Double] = [:] + /// Levels set from this phone, kept while the car stays plugged in, so + /// the view can say "you set it". + public private(set) var levelSet: [String: EVText.LevelSet] = [:] + /// What is on screen came from the phone's own cache, not the box. True + /// from a cold start until the first read. + public private(set) var fromCache = false + + @ObservationIgnored private unowned let site: SiteModel + @ObservationIgnored private let charging: ChargingWatch? + @ObservationIgnored private var ask: LiveAsk? + @ObservationIgnored private var planAsk: LiveAsk? + @ObservationIgnored private var active = 0 + @ObservationIgnored private var planWatchers = 0 + @ObservationIgnored private var token = 0 + @ObservationIgnored private var inlinePlan = false + @ObservationIgnored private var reading: Task? + @ObservationIgnored private var levelSending: Set = [] + @ObservationIgnored private var clearing: [String: Cancellable] = [:] + /// Bumped by each appearance and by `reread`: a new reason to ask. + @ObservationIgnored private var visits = 0 + + /// `cache` is what the app left for its widgets; a cold start paints the + /// car from it before the box has answered. Nil in the demo and tests. + public init(site: SiteModel, charging: ChargingWatch?, cache: CacheReader? = CacheReader.shared()) { + self.site = site + self.charging = charging + if let siteId = site.siteId, let read = cache?.read(CarEntry.self, siteId: siteId) { + points = read.value.cars.map { Self.loadpoint($0, readAtMs: read.record.readAtMs) } + readAtMs = read.record.readAtMs + fromCache = true + } + } + + /// A charger as the cache kept it: enough for the level, its source and + /// age, the cable and the power. The goal and the plan wait for the box. + static func loadpoint(_ car: CarEntry.Car, readAtMs: Double) -> Loadpoint { + var w: [(String, JSON)] = [ + ("id", .string(car.loadpointID)), + ("plugged_in", .bool(car.pluggedIn)), + ("soc_source", .string(car.socSource)), + ("current_power_w", .number(car.powerW)), + ] + if let pct = car.socPct { w.append(("current_soc", .number(pct / 100))) } + if let pct = car.targetSocPct { w.append(("target_soc", .number(pct / 100))) } + if car.socSource == "vehicle", let at = car.socReadAtMs { w.append(("vehicle_soc_age_s", .number(max(0, readAtMs - at) / 1000))) } + return Loadpoint(.object(w)) + } + + public func activate() { + active += 1 + visits += 1 + if ask == nil { + ask = LiveAsk(site: site, want: { [weak self] in + guard let self, self.active > 0, self.site.documentVisible, self.site.hasPassthrough else { return nil } + return "loadpoints \(self.visits) \(self.drawing)" + }, ask: { [weak self] in try await self?.load() }) + } + ask?.evaluate() + } + + /// Whether the stream says a car is drawing power. A change is a reason + /// to read the chargers; nothing else on lane 0 is. + var drawing: String { + guard let w = site.session.fields[Contract.FID.evW] else { return "-" } + return w >= 100 ? "on" : "off" + } + + public func deactivate() { + active = max(0, active - 1) + ask?.evaluate() + } + + /// The car view is open: read the plan's shortfall too, once a minute + /// and whenever the car's plan changes. + public func watchPlan() { + planWatchers += 1 + if planAsk == nil { + planAsk = LiveAsk(site: site, want: { [weak self] in + guard let self, self.planWatchers > 0, self.site.documentVisible, self.site.hasPassthrough, self.inlinePlan else { return nil } + let goals = self.points.filter { $0.pluggedIn && $0.schedule != nil && !$0.planPending && !$0.planOutdated } + guard !goals.isEmpty else { return nil } + return "plan \(goals.map(Self.planKey).joined(separator: ","))" + }, ask: { [weak self] in try await self?.readShortfall() }) + } + planAsk?.evaluate() + } + + public func unwatchPlan() { + planWatchers = max(0, planWatchers - 1) + planAsk?.evaluate() + } + + /// What would make a shortfall read out of date for one car. + static func planKey(_ lp: Loadpoint) -> String { + let s = lp.schedule + return "\(lp.id):\(lp.socPct ?? -1):\(s?.socPct ?? -1):\(s?.timeOfDayMinUTC ?? -1):\(lp.planWindows.count):\(Int(lp.planWindows.first?.fromMs ?? 0)):\(Int(lp.planTotalWh ?? 0))" + } + + /// Out of date: from the cache, the read failed, or the stream stopped. + /// An answer is not out of date for being minutes old: the stream would + /// have said if the car started or stopped drawing. + public var stale: Bool { + fromCache || error != nil || site.session.phase != .streaming + } + + /// Ask again now: pull to refresh, or the demo's cable. + public func reread() { + visits += 1 + ask?.evaluate() + } + + // 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 + loading = true + defer { if mine == token { loading = false } } + do { + let wire = try await site.callBox(.get, "/api/loadpoints") + guard mine == token else { return } + let list = wire?["loadpoints"]?.array ?? [] + let before = Dictionary(points.map { ($0.id, $0) }, uniquingKeysWith: { a, _ in a }) + points = list.map(Loadpoint.init) + for lp in points { + // A level set here belongs to one plug-in. + let was = before[lp.id] + if !lp.pluggedIn || (was.map { lp.sessionWh + 100 < $0.sessionWh } ?? false) { levelSet[lp.id] = nil } + } + inlinePlan = !points.isEmpty && points.allSatisfy(\.inlinePlan) + if inlinePlan { + windows = Dictionary(uniqueKeysWithValues: points.map { ($0.id, $0.planWindows) }) + planMissing = false + } + loaded = true + fromCache = false + readAtMs = site.scheduler.nowMs + error = nil + site.shareCars(points, fresh: true) + planAsk?.evaluate() + } catch { + guard mine == token else { return } + if let e = error as? BoxAPIError, points.isEmpty { + self.error = e.help + } else { + self.error = AppWords.text("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 } + let pending = wire?["meta"]?["replanning"]?.bool == true || wire?["meta"]?["outdated"]?.bool == true + let actions = wire?["plan"]?["actions"]?.array ?? [] + var out = [String: [Loadpoint.Window]]() + for lp in points { + out[lp.id] = pending || lp.planPending || lp.planOutdated ? [] : Self.chargeWindows(actions, loadpointID: lp.id) + } + windows = out + takeShortfall(wire, pending: pending) + planMissing = false + } catch { + guard mine == token else { return } + planMissing = true + } + } + + private func readShortfall() async throws { + let wire = try await site.callBox(.get, "/api/mpc/plan") + takeShortfall(wire, pending: wire?["meta"]?["replanning"]?.bool == true || wire?["meta"]?["outdated"]?.bool == true) + } + + /// A plan being remade says nothing about whether the car makes it. + private func takeShortfall(_ wire: JSON?, pending: Bool) { + var out = [String: Double]() + if !pending { + for (id, wh) in wire?["plan"]?["loadpoint_shortfall_wh"]?.object ?? [] { + if let v = wh.number, v.isFinite { out[id] = max(0, v) } + } + } + shortfallWh = out + shortfallReadAtMs = site.scheduler.nowMs + } + + /// Adjacent charging slots fold into one window; a person asks when it + /// will charge, not when the reason changes. + static func chargeWindows(_ actions: [JSON], loadpointID: String) -> [Loadpoint.Window] { + 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 + let wh = w * len / 60 + if var last = out.last, start <= last.toMs { + last.toMs = end + last.peakW = max(last.peakW ?? 0, w) + last.energyWh = (last.energyWh ?? 0) + wh + out[out.count - 1] = last + } else { + out.append(Loadpoint.Window(fromMs: start, toMs: end, peakW: w, energyWh: wh)) + } + } + return out + } + + // MARK: The level + + /// The level a person tapped. It goes to the box at once; taps made + /// while one is on its way are folded into the next send, so ±5 pressed + /// three times sends the last value, not three. + public func setLevel(_ lp: Loadpoint, pct: Double) async { + let id = lp.id + levelWanted[id] = min(100, max(0, pct.rounded())) + guard !levelSending.contains(id) else { return } + levelSending.insert(id) + defer { levelSending.remove(id) } + var sent: Double? + while let want = levelWanted[id], want != sent { + sent = want + await follow(id, .level(want), op: Contract.opLoadpointSocSet, args: [("id", .text(id)), ("soc", .number(want / 100))], into: \.levelStep, tag: "level") + if case .applied(let seen)? = levelStep[id]?.result { + levelSet[id] = EVText.LevelSet(pct: seen.map { ($0 * 100).rounded() } ?? want, atMs: site.scheduler.nowMs) + } + if levelWanted[id] == want { + // Settled and nothing newer: the box's own reading takes over. + try? await loadChargers() + if levelWanted[id] == want { levelWanted[id] = nil } + } + } + } + + /// The level to show: the box's account. A tap on its way shows as the + /// chosen chip and the receipt, never as the level: only the box's + /// answer moves the number. Between its answer and the next read, the + /// level it confirmed is the newest fact. + public func level(_ lp: Loadpoint) -> EVText.Level? { + let set = levelSet[lp.id] + guard var level = EVText.level(lp, answeredAtMs: readAtMs ?? site.nowMs, nowMs: site.nowMs, set: set) else { return nil } + if let set, (readAtMs ?? 0) < set.atMs { + level = EVText.Level(pct: set.pct, origin: .you, atMs: set.atMs, setPct: nil) + } + return level + } + + /// The chip to show as chosen: the tap on its way, else the level. + public func chosenLevel(_ lp: Loadpoint) -> Double? { + levelWanted[lp.id] ?? level(lp)?.pct + } + + // MARK: Charge now, Pause, Back to the plan + + /// Charge now: the charger's full current, held until Back to the plan + /// or an unplug. No time limit and no level gate: the level is often a + /// guess, and a guess must not lock the owner out (Fredrik, 4 Sep 2026). + public func chargeNow(_ lp: Loadpoint) async { + let range = EVText.current(lp) + await hold(lp, .chargeNow, [("id", .text(lp.id)), ("power_w", .number(EVText.watts(lp, amps: range.maxA))), ("hold_s", .int(0)), ("phase_mode", .text(range.phases == 1 ? "1p" : "3p"))]) + } + + /// A zero hold: the charger stays off until the owner chooses. + public func pause(_ lp: Loadpoint) async { + await hold(lp, .pause, [("id", .text(lp.id)), ("power_w", .int(0)), ("hold_s", .int(0))]) + } + + /// Release the hold; the plan takes back over. + public func backToPlan(_ lp: Loadpoint) async { + await hold(lp, .resume, [("id", .text(lp.id)), ("clear", .bool(true))]) + } + + private func hold(_ lp: Loadpoint, _ ask: EVText.Step.Ask, _ args: [(String, CBOR)]) async { + if let step = holdStep[lp.id], !step.settled { return } + await follow(lp.id, ask, op: Contract.opLoadpointHold, args: args, into: \.holdStep, tag: "hold") + try? await loadChargers() + charging?.refresh() + } + + /// One command, written into `into` at every stage: sent, received, + /// and what the box did or why not. + private func follow(_ id: String, _ ask: EVText.Step.Ask, op: String, args: [(String, CBOR)], into path: ReferenceWritableKeyPath, tag: String) async { + let key = "\(tag) \(id)" + clearing[key]?.cancel() + self[keyPath: path][id] = EVText.Step(ask: ask) + let result: EVText.Step.Result + do { + let r = try await site.core.command(op, args: args, onAck: { [weak self] in + guard let self, self[keyPath: path][id]?.ask == ask else { return } + self[keyPath: path][id]?.acked = true + }) + switch r.state { + case .applied: result = .applied(observed: r.observedValue) + case .superseded: result = .superseded(observed: r.observedValue) + case .unconfirmed: result = .unconfirmed(note: r.errorCode == nil ? nil : EVText.refusal(r)) + case .rejected, .expired: result = .refused(EVText.refusal(r)) + } + self[keyPath: path][id]?.acked = true + } catch { + result = .refused(EVText.failure(error)) + } + guard self[keyPath: path][id]?.ask == ask else { return } + self[keyPath: path][id]?.result = result + // A success fades once the screen shows its effect; a refusal stays + // until the next tap. + if case .applied = result { + clearing[key] = site.scheduler.after(Self.doneShownMs) { [weak self] in + guard let self, self[keyPath: path][id]?.result == result else { return } + self[keyPath: path][id] = nil + } + } + } + + static let doneShownMs: Double = 20_000 + + // MARK: The goal + + /// Save a goal in one PUT. The ready time goes as the household clock + /// and as today's UTC minute: a box that reads the clock takes it, an + /// older one reads the minute on today's date, as Core does. + public func saveGoal(_ lp: Loadpoint, socPct: Double, clock: Int, recurring: Bool, days: Int, zone: TimeZone) async { + guard (0..<1440).contains(clock) else { goalSave[lp.id] = .failed(AppWords.text("Choose a valid ready time.")); return } + guard (10...100).contains(socPct) else { goalSave[lp.id] = .failed(AppWords.text("Choose a charge target from 10 to 100 %.")); return } + guard !recurring || days & 0x7f != 0 else { goalSave[lp.id] = .failed(AppWords.text("Choose at least one day.")); return } + let minuteUTC = EVText.minuteUTC(clock: clock, at: Date(timeIntervalSince1970: site.nowMs / 1000), zone: zone) + let mask = !recurring || days & 0x7f == 0x7f ? 0 : days & 0x7f + goalSave[lp.id] = .saving + do { + _ = try await site.callBox(.put, "/api/loadpoints/\(Self.escape(lp.id))/schedule", body: [ + "soc": .number(socPct / 100), + "time_of_day_min": .number(Double(clock)), + "time_of_day_min_utc": .number(Double(minuteUTC)), + "recurring": .bool(recurring), + "days": .number(Double(mask)), + "surplus_unlock_bat_soc": .number((lp.schedule?.surplusUnlockPct ?? 0) / 100), + ]) + goalSave[lp.id] = .saved + } catch let e as BoxAPIError { + goalSave[lp.id] = .failed(e.help) + } catch { + goalSave[lp.id] = .failed(AppWords.text("That did not go through. Try again.")) + } + try? await loadChargers() + charging?.refresh() + } + + public func removeGoal(_ lp: Loadpoint) async { + goalSave[lp.id] = .saving + do { + _ = try await site.callBox(.delete, "/api/loadpoints/\(Self.escape(lp.id))/schedule") + // The DELETE confirms the removal even if the reread fails. + if let i = points.firstIndex(where: { $0.id == lp.id }) { + points[i].schedule = nil + points[i].targetSocPct = nil + } + goalSave[lp.id] = .removed + } catch let e as BoxAPIError { + goalSave[lp.id] = .failed(e.help) + } catch { + goalSave[lp.id] = .failed(AppWords.text("That did not go through. Try again.")) + } + try? await loadChargers() + charging?.refresh() + } + + public static func goalText(_ save: GoalSave) -> String { + switch save { + case .saving: return AppWords.text("Saving the goal…") + case .saved: return AppWords.text("Goal saved. FTW is updating the plan.") + case .removed: return AppWords.text("Goal removed.") + case .failed(let help): return help + } + } + + static func escape(_ id: String) -> String { + id.addingPercentEncoding(withAllowedCharacters: .urlPathAllowed.subtracting(CharacterSet(charactersIn: "/"))) ?? id + } + + // MARK: What the view shows + + /// The household zone, from the box's hello. + public var zone: TimeZone { EVText.householdZone(boxTZ: site.session.box?.tz) } + + public func readiness(_ lp: Loadpoint) -> EVText.Readiness? { + EVText.readiness(lp, level: level(lp), shortfallWh: shortfallWh[lp.id], nowMs: site.nowMs, zone: zone) + } + + public func outcome(level lp: Loadpoint) -> EVText.Outcome? { + levelStep[lp.id].map { EVText.outcome($0, lp) } + } + + public func outcome(hold lp: Loadpoint) -> EVText.Outcome? { + holdStep[lp.id].map { EVText.outcome($0, lp) } + } + + public func holdSending(_ lp: Loadpoint) -> Bool { + holdStep[lp.id].map { !$0.settled } ?? false + } +} diff --git a/appleApp/FTWKit/Sources/FTWKit/State/NowModels.swift b/appleApp/FTWKit/Sources/FTWKit/State/NowModels.swift new file mode 100644 index 0000000..ee71249 --- /dev/null +++ b/appleApp/FTWKit/Sources/FTWKit/State/NowModels.swift @@ -0,0 +1,346 @@ +import Foundation +import FTWShared +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 + site.shareCars(next, fresh: fresh) + 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 + site.sharePrices(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 + } +} + +/// "Is FTW in control?" in one calm line, from the stream alone: lane 0 +/// carries the box's dispatch block and each source's state, so the line +/// needs no request and no timer. It is green only while frames arrive +/// and the box says nothing blocks it; from a cache it is grey and dated. +/// Core's per-device control feedback joins it when lane 0 carries it (M5). +public enum InControl { + public enum Tone: Equatable, Sendable { case ok, warning, neutral, stale } + + public struct Answer: Equatable, Sendable { + public let tone: Tone + public let title: String + public let detail: String? + + public init(tone: Tone, title: String, detail: String?) { + self.tone = tone + self.title = title + self.detail = detail + } + } + + /// - streaming: frames are arriving now over a live carrier. + /// - sources: the box's own account of each device it reads. + public static func answer(streaming: Bool, lastKnownMs: Double?, dispatchBlockedBy: [String], sources: [String: Source], zone: TimeZone = .current) -> Answer { + guard streaming else { + return Answer(tone: .stale, title: AppWords.text("Not live right now"), detail: lastKnownMs.map { AppWords.format("Last known %@.", AppWords.clock($0, zone: zone)) } ?? AppWords.text("Waiting for your box.")) + } + if !dispatchBlockedBy.isEmpty { + return Answer(tone: .warning, title: AppWords.text("FTW is not in control right now"), detail: AppWords.text("A meter stopped reporting. Your home runs on grid power as usual.")) + } + let quiet = sources.values.filter { $0.state == .stale || $0.state == .down }.sorted { $0.name < $1.name } + if let first = quiet.first { + let title = quiet.count == 1 ? AppWords.format("%@ is not reporting", noun(first.kind)) : AppWords.format("%@ devices are not reporting", String(quiet.count)) + return Answer(tone: .warning, title: title, detail: first.name.isEmpty ? nil : first.name) + } + return Answer(tone: .ok, title: AppWords.text("Yes, FTW is in control"), detail: AppWords.text("Nothing blocks it, and every device reports.")) + } + + static func noun(_ kind: String) -> String { + switch kind { + case "meter": return AppWords.text("The meter") + case "inverter": return AppWords.text("The inverter") + case "battery": return AppWords.text("The battery") + case "charger", "ev": return AppWords.text("The charger") + default: return AppWords.text("A device") + } + } +} diff --git a/appleApp/FTWKit/Sources/FTWKit/State/PairModel.swift b/appleApp/FTWKit/Sources/FTWKit/State/PairModel.swift new file mode 100644 index 0000000..24e5b21 --- /dev/null +++ b/appleApp/FTWKit/Sources/FTWKit/State/PairModel.swift @@ -0,0 +1,95 @@ +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, 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? + /// 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 site = try await app.pairing.pair(scanned: raw) + app.paired(site.siteId) + } catch is PasskeyCancelled { + // A dismissed sheet is not a fault and gets no error voice. + stage = .intro + } 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 + } + + 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/SiteModel.swift b/appleApp/FTWKit/Sources/FTWKit/State/SiteModel.swift new file mode 100644 index 0000000..12a6db6 --- /dev/null +++ b/appleApp/FTWKit/Sources/FTWKit/State/SiteModel.swift @@ -0,0 +1,526 @@ +import Foundation +import FTWShared +import Observation +import os + +private let log = Logger(subsystem: "energy.ftw.app", category: "widgets") + +/// The bridge between the session and the screens. +/// +/// 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? + /// What widgets read. Nil for the demo and in tests. + @ObservationIgnored private let shared: AppGroupFiles? + @ObservationIgnored private var lastCars: (entry: CarEntry, atMs: Double)? + /// The prices last left for the widgets, so the same prices again are + /// neither written nor reloaded. + @ObservationIgnored private var lastPrices: PriceEntry? + /// The latest write to `shared`. Each waits for the one before, so they + /// land in the order they were asked for. + @ObservationIgnored private var writing: Task? + /// Called after every write to `shared`, so the app can wake its widgets. + @ObservationIgnored public var sharedWritten: (@MainActor () -> Void)? + @ObservationIgnored private var attemptStartedAtMs: Double + @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?, shared: AppGroupFiles? = nil, isDemo: Bool = false) { + self.siteId = siteId + self.scheduler = scheduler + self.files = files + self.shared = isDemo ? nil : shared + self.isDemo = isDemo + nowMs = scheduler.nowMs + attemptStartedAtMs = scheduler.nowMs + 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 = [:] + } + + /// The app went to the background. Write what is on screen and close the + /// session: iOS freezes the app within seconds anyway, and an open socket + /// holds one of the four streams the relay allows a home. The readings + /// stay, and the band dates them until a new carrier streams again. + public func suspend() { + guard !destroyed else { return } + persistNow() + core.close() + // After the close, so no last change of rate goes out on the wire. + setVisible(false) + } + + /// Match the stream's cadence to whether anyone can see it. + public func setVisible(_ visible: Bool) { + let was = documentVisible + 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 relay turned this phone away: the home has as many phones + /// watching as it serves. Said as such, never as a fault. + public var busy: Bool { session.busy } + + /// The band for this home, read the way the web app's shell reads it. + public func freshness(noCarrier: Bool) -> Freshness.Band { + Freshness.band( + carrier: carrier, + transport: session.carrier, + srcState: srcState, + ageMs: ageMs, + phase: session.phase, + waitMs: connectionWaitMs, + bootPct: session.boot?.pct, + noCarrier: noCarrier, + busy: busy + ) + } + + /// The sources behind what the Now screen draws, named by the box. + var nowSourceIDs: [String] { + var ids = [String]() + 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 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) + } + + // MARK: What widgets read + + /// A rewrite of the same car is skipped for this long. The charger is + /// read every five seconds while the app is open; a widget does not need + /// each of those. + static let carRewriteMs: Double = 5 * 60_000 + + /// Leave today's and tomorrow's prices for the widgets. The same prices + /// again are skipped: the widgets' timeline already holds them, and a + /// reload would only redraw it. Returns the write, for a caller that + /// must not end before it lands. + @discardableResult + func sharePrices(_ prices: Prices, fromMs: Double) -> Task? { + guard let siteId, let shared else { return nil } + let entry = PriceEntry(fromMs: fromMs, prices: prices) + guard entry != lastPrices else { return nil } + lastPrices = entry + let record = CacheRecord( + siteId: siteId, + carrier: session.carrier, + srcState: prices.stale ? .stale : .live, + readAtMs: scheduler.nowMs, + value: entry + ) + return write(record, to: shared) + } + + /// Leave the chargers for the widgets, as the box just described them. + /// `fresh` says whether the box's own reading of the charger is current. + func shareCars(_ points: [Loadpoint], fresh: Bool) { + guard let siteId, let shared else { return } + let now = scheduler.nowMs + let entry = CarEntry(cars: points.map { lp in + CarEntry.Car( + loadpointID: lp.id, + pluggedIn: lp.pluggedIn, + socPct: lp.socPct, + socSource: lp.socSource, + socReadAtMs: lp.socReadAtMs(answeredAtMs: now), + powerW: lp.powerW, + targetSocPct: lp.targetSocPct + ) + }) + if let last = lastCars, last.entry.sameReading(entry), now - last.atMs < Self.carRewriteMs { return } + lastCars = (entry, now) + let answering = fresh && points.allSatisfy { $0.charger?.available != false } + write(CacheRecord(siteId: siteId, carrier: session.carrier, srcState: answering ? .live : .stale, readAtMs: now, value: entry), to: shared) + } + + /// Encoding and the file write run off the main actor. A sign-out + /// asked for meanwhile wins: the write is dropped rather than putting + /// the home back (`AppGroupFiles.clearCount`). + @discardableResult + private func write(_ record: CacheRecord, to shared: AppGroupFiles) -> Task? { + guard !destroyed else { return nil } + let asked = shared.clearCount + let previous = writing + let task = Task { [weak self] in + await previous?.value + do { + guard try await shared.write(record, asked: asked) else { return } + log.info("wrote \(Value.name, privacy: .public) for widgets, source \(record.srcState.rawValue, privacy: .public)") + guard let self, !self.destroyed else { return } + self.sharedWritten?() + } catch { + // A widget showing yesterday's record with its date is + // better than no app; the next read tries again. + log.error("could not write \(Value.name, privacy: .public) for widgets: \(error.localizedDescription, privacy: .public)") + if Value.self == PriceEntry.self { self?.lastPrices = nil } + } + } + writing = task + return task + } + + /// Where the prices the widgets hold run out. Nil when they hold none. + /// Read from what this process wrote, or off the main actor from disk + /// after a cold launch. + public func sharedPricesEndMs() async -> Double? { + if let lastPrices { return Self.end(lastPrices) } + guard let siteId, let shared else { return nil } + return await Self.readEnd(shared, siteId: siteId) + } + + @concurrent + private nonisolated static func readEnd(_ shared: AppGroupFiles, siteId: String) async -> Double? { + shared.read(PriceEntry.self, siteId: siteId).flatMap { end($0.value) } + } + + private nonisolated static func end(_ entry: PriceEntry) -> Double? { + entry.prices.slots.map { $0.startMs + $0.durationMs }.max() + } + + /// The background refresh: wait for the stream, ask for today's and + /// tomorrow's prices as the price card does, and leave them for the + /// widgets. Returns what was written, or nil when nothing was: no + /// stream within `waitMs`, a box without prices, or a failed ask. + /// + /// The caller builds the carrier and closes it after. + @discardableResult + public func refreshSharedPrices(calendar: Calendar = .current, waitMs: Double = 20_000) async -> Prices? { + guard siteId != nil, shared != nil, !destroyed else { return nil } + guard await streaming(within: waitMs) else { + log.notice("background prices: no stream within \(Int(waitMs / 1000), privacy: .public) s") + return nil + } + guard session.caps.contains(Contract.capPriceSpot) else { + log.notice("background prices: this box offers no prices") + return nil + } + let from = calendar.startOfDay(for: Date(timeIntervalSince1970: nowMs / 1000)).timeIntervalSince1970 * 1000 + do { + let wire = try await prices(fromMs: from, toMs: from + PriceModel.horizonMs) + sharePrices(wire, fromMs: from) + // iOS may suspend the app once the task returns: land the write + // first, or the one already on its way with these prices. + await writing?.value + return wire + } catch { + log.error("background prices: \(error.localizedDescription, privacy: .public)") + return nil + } + } + + /// True once the session streams, false if it has not within `ms`. + private func streaming(within ms: Double) async -> Bool { + if session.phase == .streaming { return true } + let wait = StreamWait() + let ok = await withCheckedContinuation { cont in + wait.continuation = cont + wait.token = observe { [weak self, wait] in + if self?.session.phase == .streaming { wait.settle(true) } + } + wait.timer = scheduler.after(ms) { [wait] in wait.settle(false) } + } + if let token = wait.token { unobserve(token) } + return ok + } +} + +/// One wait for the stream, settled once. The timer settles it even if the +/// model is gone, so the background task always ends. +@MainActor +private final class StreamWait { + var continuation: CheckedContinuation? + var token: Int? + var timer: Cancellable? + + func settle(_ ok: Bool) { + guard let cont = continuation else { return } + continuation = nil + timer?.cancel() + cont.resume(returning: ok) + } +} diff --git a/appleApp/FTWKit/Sources/FTWKit/Store/SealedFiles.swift b/appleApp/FTWKit/Sources/FTWKit/Store/SealedFiles.swift new file mode 100644 index 0000000..95a0929 --- /dev/null +++ b/appleApp/FTWKit/Sources/FTWKit/Store/SealedFiles.swift @@ -0,0 +1,83 @@ +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)) + } +} 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..58b2fe7 --- /dev/null +++ b/appleApp/FTWKit/Sources/FTWKit/Store/Sites.swift @@ -0,0 +1,96 @@ +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? + 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?, addedAtMs: Double, lastSeenAtMs: Double) { + self.siteId = siteId + self.label = label + self.boxStaticKey = boxStaticKey + self.rendezvousSecret = rendezvousSecret + self.pairingCode = pairingCode + self.lanHint = lanHint + 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..70c82c8 --- /dev/null +++ b/appleApp/FTWKit/Sources/FTWKit/Support/JSON.swift @@ -0,0 +1,255 @@ +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 } +} + +/// 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/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/Sources/FTWShared/Cache.swift b/appleApp/FTWKit/Sources/FTWShared/Cache.swift new file mode 100644 index 0000000..fb6bf43 --- /dev/null +++ b/appleApp/FTWKit/Sources/FTWShared/Cache.swift @@ -0,0 +1,260 @@ +import Foundation +import Synchronization + +// What the app leaves on disk for its widgets, and later for a notification +// extension: one home's prices and its car, each with where it came from +// and when. Widgets read this and nothing else. They never open the relay +// and never touch a key. +// +// The app writes and widgets read. Both go through the same records, so a +// widget cannot draw a reading the app never had. + +/// The shape of something kept in the cache. +public protocol CacheValue: Codable, Sendable, Equatable { + /// The record lives at "-". + static var name: String { get } + /// Bumped when the shape changes. A record of another shape is not read. + static var version: Int { get } +} + +/// One cached reading of one home. +/// +/// Freshness is two fields here as everywhere. `carrier` is how the reading +/// reached the phone when it was taken, `srcState` whether the box's own +/// source was answering then. Read back, a record is always `.cache` +/// (`CacheRead.carrier`), so old data never looks live. +public struct CacheRecord: Codable, Sendable, Equatable { + public var version: Int + public var siteId: String + public var carrier: CarrierKind + public var srcState: SourceState + /// Wall clock on the phone when the reading arrived. + public var readAtMs: Double + public var value: Value + + public init(siteId: String, carrier: CarrierKind, srcState: SourceState, readAtMs: Double, value: Value) { + version = Value.version + self.siteId = siteId + self.carrier = carrier + self.srcState = srcState + self.readAtMs = readAtMs + self.value = value + } +} + +/// Which source a record came from. +public enum CacheOrigin: String, Sendable { + /// Written by the app, from its own session with the box. + case app + // FTW Cloud, the opt-in state cache, would be a second case here, read by + // its own `CacheSource` into the same records. +} + +/// Somewhere records can come from. The App Group files are the only source +/// today. A copy from FTW Cloud becomes one more source that reads into the +/// same `CacheRecord`, so nothing that reads the cache has to change. +public protocol CacheSource: Sendable { + var origin: CacheOrigin { get } + func read(_ type: Value.Type, siteId: String) -> CacheRecord? + /// Homes with a record of this kind, most recently written first. + func siteIDs(_ type: Value.Type) -> [String] +} + +/// A record as a reader hands it out. +public struct CacheRead: Sendable { + public let record: CacheRecord + public let origin: CacheOrigin + + public init(record: CacheRecord, origin: CacheOrigin) { + self.record = record + self.origin = origin + } + + public var value: Value { record.value } + /// Always `.cache`. A file on this phone is not a connection to the box. + public var carrier: CarrierKind { .cache } + /// As it was when the reading arrived. Never better. + public var srcState: SourceState { record.srcState } + + public func ageMs(nowMs: Double) -> Double { max(0, nowMs - record.readAtMs) } +} + +/// Reads the newest record any source holds. +public struct CacheReader: Sendable { + public let sources: [any CacheSource] + + public init(sources: [any CacheSource]) { + self.sources = sources + } + + /// The App Group files, when this process has the group. + public static func shared(bundle: Bundle = .main) -> CacheReader? { + AppGroupFiles.shared(bundle: bundle).map { CacheReader(sources: [$0]) } + } + + public func read(_ type: Value.Type, siteId: String) -> CacheRead? { + var best: CacheRead? + for source in sources { + guard let record = source.read(type, siteId: siteId) else { continue } + if best == nil || record.readAtMs > best!.record.readAtMs { + best = CacheRead(record: record, origin: source.origin) + } + } + return best + } + + /// The home most recently written: the one the app had open last. + public func latestSiteID(_ type: Value.Type) -> String? { + sources.lazy.compactMap { $0.siteIDs(type).first }.first + } +} + +/// Files in the App Group container, which the app and its widgets share. +/// +/// Not sealed: prices are not secret and the car record says no more than +/// the charger's own app. Files carry `completeUntilFirstUserAuthentication`, +/// so a widget can read them while the phone is locked, but not after a +/// restart before the first unlock. +public struct AppGroupFiles: CacheSource { + /// The Info.plist key that names the App Group. The build fills it from + /// the same setting as the entitlements, so Swift never spells the name. + public static let infoKey = "FTWAppGroup" + + public let directory: URL + public var origin: CacheOrigin { .app } + + public init(directory: URL) { + self.directory = directory + } + + /// The container named in `bundle`'s Info.plist. Nil when the process has + /// no such group: a test, or a build without the entitlement. + public static func shared(bundle: Bundle = .main) -> AppGroupFiles? { + guard let group = bundle.object(forInfoDictionaryKey: infoKey) as? String, !group.isEmpty, + let container = FileManager.default.containerURL(forSecurityApplicationGroupIdentifier: group) else { return nil } + return AppGroupFiles(directory: container.appendingPathComponent("Library/Application Support/cache", isDirectory: true)) + } + + func url(_ name: String, _ siteId: String) -> URL { + directory.appendingPathComponent("\(name)-\(siteId)") + } + + public func read(_ type: Value.Type, siteId: String) -> CacheRecord? { + guard let data = try? Data(contentsOf: url(Value.name, siteId)), + let record = try? JSONDecoder().decode(CacheRecord.self, from: data), + record.version == Value.version, record.siteId == siteId else { return nil } + return record + } + + public func siteIDs(_ type: Value.Type) -> [String] { + let prefix = "\(Value.name)-" + let keys: Set = [.contentModificationDateKey] + let files = (try? FileManager.default.contentsOfDirectory(at: directory, includingPropertiesForKeys: Array(keys))) ?? [] + return files + .filter { $0.lastPathComponent.hasPrefix(prefix) } + .map { (id: String($0.lastPathComponent.dropFirst(prefix.count)), at: (try? $0.resourceValues(forKeys: keys))?.contentModificationDate ?? .distantPast) } + .sorted { $0.at > $1.at } + .map(\.id) + } + + /// Only the app writes. Atomic, so a widget never reads half a file. + public func write(_ record: CacheRecord) throws { + try Self.clears.withLock { _ in try store(record) } + } + + /// Writes off the caller's thread. `asked` is `clearCount` when the write + /// was asked for: a clear since then means the home was signed out, so + /// the write is dropped rather than putting the home back. Returns + /// whether it wrote. + @concurrent + public nonisolated func write(_ record: CacheRecord, asked: Int) async throws -> Bool { + try Self.clears.withLock { counts in + guard counts[directory, default: 0] == asked else { return false } + try store(record) + return true + } + } + + /// Everything, for signing out. A widget then has nothing to draw. + public func removeAll() { + Self.clears.withLock { counts in + counts[directory, default: 0] += 1 + try? FileManager.default.removeItem(at: directory) + } + } + + /// How many times this process has cleared this cache. Writes and + /// clears take the same lock, so they land in the order they were asked + /// for. + public var clearCount: Int { Self.clears.withLock { $0[directory, default: 0] } } + private static let clears = Mutex<[URL: Int]>([:]) + + private func store(_ record: CacheRecord) throws { + try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) + let data = try JSONEncoder().encode(record) + try data.write(to: url(Value.name, record.siteId), options: [.atomic, .completeFileProtectionUntilFirstUserAuthentication]) + } +} + +// MARK: - What is kept + +/// One home's prices across today and tomorrow, as the box served them. +public struct PriceEntry: CacheValue { + public static let name = "prices" + public static let version = 1 + + /// Local midnight the window starts at. + public var fromMs: Double + public var prices: Prices + + public init(fromMs: Double, prices: Prices) { + self.fromMs = fromMs + self.prices = prices + } +} + +/// The cars on one home's chargers, as the box last described them. +public struct CarEntry: CacheValue { + public static let name = "car" + public static let version = 1 + + public struct Car: Codable, Sendable, Equatable { + public var loadpointID: String + public var pluggedIn: Bool + /// Whole percent. Nil when the box has no level for the car. + public var socPct: Double? + /// Where the level came from, in the box's words: vehicle, inferred, + /// assumed or completed. A level the box assumed is not a reading. + public var socSource: String + /// Wall clock of the level itself. For a level from the car, when + /// the car reported it, which can be well before the record. + public var socReadAtMs: Double? + /// Charger power, site convention: positive while charging. + public var powerW: Double + /// The goal the box holds, whole percent. + public var targetSocPct: Double? + + public init(loadpointID: String, pluggedIn: Bool, socPct: Double?, socSource: String, socReadAtMs: Double?, powerW: Double, targetSocPct: Double?) { + self.loadpointID = loadpointID + self.pluggedIn = pluggedIn + self.socPct = socPct + self.socSource = socSource + self.socReadAtMs = socReadAtMs + self.powerW = powerW + self.targetSocPct = targetSocPct + } + } + + public var cars: [Car] + + public init(cars: [Car]) { + self.cars = cars + } + + /// The same cars at the same levels, whenever each level was read. A + /// rewrite of that tells a widget nothing new. + public func sameReading(_ other: CarEntry) -> Bool { + func levels(_ e: CarEntry) -> [Car] { e.cars.map { var c = $0; c.socReadAtMs = nil; return c } } + return levels(self) == levels(other) + } +} diff --git a/appleApp/FTWKit/Sources/FTWShared/Freshness.swift b/appleApp/FTWKit/Sources/FTWShared/Freshness.swift new file mode 100644 index 0000000..521b286 --- /dev/null +++ b/appleApp/FTWKit/Sources/FTWShared/Freshness.swift @@ -0,0 +1,93 @@ +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, busy } + + 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, + busy: Bool = false + ) -> Band { + let reaching = !noCarrier && [.idle, .handshaking, .subscribing, .failed, .booting].contains(phase) + let connected = carrier == .relay || carrier == .webrtc + // The relay turned this phone away because the home already has as + // many phones watching as it serves. Not a fault, and not ours to fix. + let turnedAway = busy && !connected && reaching + + let tone: Tone = connected ? (srcState == .live ? .live : .stale) : turnedAway ? .busy : (reaching ? .reaching : .lost) + + let message: String + if connected { + let webrtc = carrier == .webrtc + switch srcState { + case .live: message = webrtc ? Words.text("Live at home") : Words.text("Live via encrypted relay") + case .never: message = webrtc ? Words.text("Home link connected · no reading yet") : Words.text("Encrypted relay connected · no reading yet") + case .down: message = webrtc ? Words.text("Home link connected · a device stopped responding") : Words.text("Encrypted relay connected · a device stopped responding") + default: message = webrtc ? Words.text("Home link connected · readings") : Words.text("Encrypted relay connected · readings") + } + } else if phase == .terminated { + // The box is reachable; it told this phone to leave. + message = Words.text("Access ended") + } else if turnedAway { + message = Words.text("FTW is busy") + } else if phase == .booting { + message = Words.text("Your box is starting") + } else if reaching { + if phase == .failed { + message = Words.text("Reconnecting to your box") + } else if phase == .subscribing { + message = Words.text("Waiting for live readings") + } else if phase == .handshaking, transport == .relay { + message = Words.text("Securing encrypted relay") + } else if phase == .handshaking, transport == .webrtc { + message = Words.text("Securing home link") + } else { + message = Words.text("Connecting to your box") + } + } else { + message = Words.text("Can't reach your box") + } + + var wait: String? + if reaching, !turnedAway { + 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) + } +} diff --git a/appleApp/FTWKit/Sources/FTWShared/Power.swift b/appleApp/FTWKit/Sources/FTWShared/Power.swift new file mode 100644 index 0000000..5afcc38 --- /dev/null +++ b/appleApp/FTWKit/Sources/FTWShared/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 Words.text("unknown") } + let s = Int(ms / 1000) + if s < 5 { return Words.text("just now") } + if s < 60 { return Words.format("%lds ago", s) } + let m = s / 60 + if m < 60 { return Words.format("%ld min ago", m) } + let h = m / 60 + if h < 24 { return Words.format("%ld h ago", h) } + return Words.format("%ld d ago", h / 24) + } + + /// JavaScript's toFixed, which rounds half away from zero on the + /// 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/FTWShared/PriceGlance.swift b/appleApp/FTWKit/Sources/FTWShared/PriceGlance.swift new file mode 100644 index 0000000..950ac0c --- /dev/null +++ b/appleApp/FTWKit/Sources/FTWShared/PriceGlance.swift @@ -0,0 +1,316 @@ +import Foundation + +/// What a price widget shows at one moment, read from the cached prices and +/// nothing else. +/// +/// A widget cannot ask the box anything, so its timeline is worked out in +/// advance: one moment now and one at every price boundary the box sent, +/// at whatever resolution the box sent it. Where the prices stop, the last +/// moment says so. It never stretches the last price over hours the market +/// has not priced, and never starts the day over with yesterday's. +public enum PriceGlance { + /// How far ahead the medium widget's curve reaches. + public static let curveMs: Double = 12 * 3_600_000 + + /// What the box's total holds besides the market price. The wire has + /// spot and total but not the settings between them, so this is read off + /// the two figures: Core charges (spot + grid fee) × (1 + VAT). + public enum Included: Equatable, Sendable { + case gridFeeAndVAT, vat, gridFee + /// The total is the market price and nothing else. + case spotOnly + /// Something is added, but the market barely moved, so a fee cannot + /// be told from a percentage. + case surcharges + } + + public enum State: Equatable, Sendable { + /// The app has left nothing: not paired, or signed out. + case nothing + /// A price covers this moment. + case price + /// No price covers this moment, and the next one starts at `nextMs`. + case gap(nextMs: Double) + /// No price covers this moment or anything after it. + case ended(lastMs: Double) + } + + public struct Moment: Equatable, Sendable { + public let atMs: Double + public let state: State + /// `.cache` whenever there is a record: a file on this phone is not a + /// connection to the box. `.none` when there is nothing. + public let carrier: CarrierKind + /// As it was when the app fetched the prices. Never better. + public let srcState: SourceState + /// Wall clock on the phone when the app fetched the prices. + public let readAtMs: Double? + public let currency: String + public let current: PriceSlot? + public let cheapest: PriceStrip.Block? + /// Slots from the current one to `curveMs` ahead. + public let curve: [PriceStrip.Bar] + public let meanMinor: Double? + public let included: Included + + /// "42.3", the price now. Nil when no price covers this moment. + public let priceText: String? + /// "öre/kWh" + public let unitText: String + /// "öre", for one short line. + public let unitLabel: String + /// "incl. grid fee and VAT" + public let includedText: String + /// "Cheapest 02–04" + public let cheapestText: String? + /// "cheapest 02:45": the start alone, for one short line. + public let cheapestStartText: String? + /// Why there is no price: "No prices after 24:00 · open FTW". + public let emptyText: String? + /// "Fetched 13:05" + public let fetchedText: String? + + /// Whether the widget should go grey: no price for this moment. + public var isEmpty: Bool { state != .price } + + /// One line for the lock screen's inline slot, which shares its + /// width with the date. Without a price it only sends the reader to + /// the app; the other families say when the prices ended. + public var inlineText: String { + guard let priceText else { return GlanceWord.noPricesShort.text } + let price = "\(priceText) \(unitLabel)" + return cheapestStartText.map { "\(price) · \($0)" } ?? price + } + } + + /// The timeline: one moment now and one at every boundary ahead, the + /// last where the prices end. + public static func moments(_ read: CacheRead?, nowMs: Double, calendar: Calendar = .current) -> [Moment] { + guard let read else { return [moment(nil, atMs: nowMs, calendar: calendar)] } + // Sorted and read once, not once per moment. + let slots = usable(read.value.prices) + let included = included(slots) + var edges = Set() + for s in slots { + edges.insert(s.startMs) + edges.insert(s.startMs + s.durationMs) + } + let ahead = edges.filter { $0 > nowMs }.sorted() + return ([nowMs] + ahead).map { moment(read, slots: slots, included: included, atMs: $0, calendar: calendar) } + } + + /// The widget at one moment. + public static func moment(_ read: CacheRead?, atMs t: Double, calendar: Calendar = .current) -> Moment { + guard let read else { + return Moment( + atMs: t, state: .nothing, carrier: .none, srcState: .never, readAtMs: nil, + currency: "", current: nil, cheapest: nil, curve: [], meanMinor: nil, included: .spotOnly, + priceText: nil, unitText: "", unitLabel: "", includedText: "", cheapestText: nil, cheapestStartText: nil, + emptyText: GlanceWord.openToSee.text, fetchedText: nil + ) + } + let slots = usable(read.value.prices) + return moment(read, slots: slots, included: included(slots), atMs: t, calendar: calendar) + } + + private static func moment(_ read: CacheRead, slots: [PriceSlot], included: Included, atMs t: Double, calendar: Calendar) -> Moment { + let prices = read.value.prices + let unit = PriceUnits.unit(prices.currency) + let current = slots.first { t >= $0.startMs && t < $0.startMs + $0.durationMs } + + let state: State + if current != nil { + state = .price + } else if let next = slots.first(where: { $0.startMs > t }) { + state = .gap(nextMs: next.startMs) + } else { + state = .ended(lastMs: slots.last.map { $0.startMs + $0.durationMs } ?? read.value.fromMs) + } + + var cheapest: PriceStrip.Block? + var curve: [PriceStrip.Bar] = [] + var mean: Double? + if current != nil { + let summary = PriceStrip.summary(Prices(zone: prices.zone, currency: prices.currency, slots: slots, stale: prices.stale), nowMs: t) + cheapest = summary.cheapest + curve = summary.bars.filter { $0.startMs < t + curveMs } + mean = summary.meanMinor + } + + let clock = Clock(calendar: calendar) + var emptyText: String? + switch state { + case .nothing, .price: emptyText = nil + case .gap(let next): emptyText = GlanceWord.noPriceUntil.format(clock.at(next, asEnd: false, from: t)) + case .ended(let last): + // A box that sent no slots at all has no last hour to name. + emptyText = slots.isEmpty ? GlanceWord.openToSee.text : GlanceWord.noPricesAfter.format(clock.at(last, asEnd: true, from: t)) + } + + return Moment( + atMs: t, + state: state, + carrier: read.carrier, + srcState: read.srcState, + readAtMs: read.record.readAtMs, + currency: prices.currency, + current: current, + cheapest: cheapest, + curve: curve, + meanMinor: mean, + included: included, + priceText: current.map { PriceStrip.text($0.totalMinor, prices.currency) }, + unitText: unit.perKwh, + unitLabel: unit.label, + includedText: text(included), + cheapestText: cheapest.map { clock.cheapest($0, from: t) }, + cheapestStartText: cheapest.map { clock.cheapestStart($0, from: t) }, + emptyText: emptyText, + fetchedText: GlanceWord.fetched.format(clock.at(read.record.readAtMs, asEnd: false, from: t)) + ) + } + + /// What the total holds, from a straight line through (spot, total). + /// Rounding to whole minor units blurs each point by half a unit, so the + /// line needs some spread in spot before its slope means anything. + public static func included(_ slots: [PriceSlot]) -> Included { + guard !slots.isEmpty, slots.contains(where: { abs($0.totalMinor - $0.spotMinor) >= 1 }) else { return .spotOnly } + let spots = slots.map(\.spotMinor) + guard let lo = spots.min(), let hi = spots.max(), hi - lo >= 4 else { return .surcharges } + let n = Double(slots.count) + let mx = spots.reduce(0, +) / n + let my = slots.map(\.totalMinor).reduce(0, +) / n + var sxx = 0.0, sxy = 0.0 + for s in slots { + sxx += (s.spotMinor - mx) * (s.spotMinor - mx) + sxy += (s.spotMinor - mx) * (s.totalMinor - my) + } + let slope = sxy / sxx + guard slope.isFinite, slope > 0 else { return .surcharges } + let fee = (my - slope * mx) / slope + switch (fee >= 1, slope > 1.03) { + case (true, true): return .gridFeeAndVAT + case (false, true): return .vat + case (true, false): return .gridFee + case (false, false): return .surcharges + } + } + + public static func text(_ included: Included) -> String { + switch included { + case .gridFeeAndVAT: return GlanceWord.inclGridFeeAndVAT.text + case .vat: return GlanceWord.inclVAT.text + case .gridFee: return GlanceWord.inclGridFee.text + case .spotOnly: return GlanceWord.spotOnly.text + case .surcharges: return GlanceWord.inclSurcharges.text + } + } + + /// The widget's name and line in the gallery. + public static var title: String { GlanceWord.title.text } + public static var summary: String { GlanceWord.summary.text } + public static var priceNow: String { GlanceWord.priceNow.text } + + static func usable(_ prices: Prices) -> [PriceSlot] { + prices.slots.filter { $0.totalMinor.isFinite && $0.durationMs > 0 }.sorted { $0.startMs < $1.startMs } + } +} + +/// Every sentence a price widget shows. The English is the key, and the +/// Swedish lives in `Resources/sv.lproj`; a test checks each one has it. +enum GlanceWord: String, CaseIterable { + case title = "Electricity price" + case summary = "The price now and when it is cheapest next, from what FTW last fetched." + case priceNow = "Price now" + case cheapest = "Cheapest %@" + case cheapestNow = "Cheapest now until %@" + case tomorrow = "tomorrow %@" + case cheapestStart = "cheapest %@" + case cheapestStartNow = "cheapest now" + case noPricesAfter = "No prices after %@ · open FTW" + case noPriceUntil = "No price until %@ · open FTW" + case openToSee = "Open FTW to see prices" + case noPricesShort = "No prices · open FTW" + case fetched = "Fetched %@" + case inclGridFeeAndVAT = "incl. grid fee and VAT" + case inclVAT = "incl. VAT" + case inclGridFee = "incl. grid fee" + case spotOnly = "spot price only" + case inclSurcharges = "incl. surcharges" + + var text: String { Words.text(rawValue) } + + func format(_ arg: String) -> String { + String(format: text, arg) + } +} + +/// Times on the household's clock, 24-hour, as the box's own page writes +/// them. An end at midnight reads 24:00 of the day it ends, not 00:00 of the +/// next. A time on another day than the moment names that day, because a +/// widget can sit unread for days. +struct Clock { + let calendar: Calendar + + struct Wall { + let day: Date + let hour: Int + let minute: Int + + var hm: String { String(format: "%02d:%02d", hour, minute) } + } + + func wall(_ ms: Double, asEnd: Bool) -> Wall { + let date = Date(timeIntervalSince1970: ms / 1000) + let c = calendar.dateComponents([.hour, .minute], from: date) + let day = calendar.startOfDay(for: date) + if asEnd, c.hour == 0, c.minute == 0, let before = calendar.date(byAdding: .day, value: -1, to: day) { + return Wall(day: before, hour: 24, minute: 0) + } + return Wall(day: day, hour: c.hour ?? 0, minute: c.minute ?? 0) + } + + func day(_ ms: Double) -> Date { + calendar.startOfDay(for: Date(timeIntervalSince1970: ms / 1000)) + } + + /// "13:05", or "Tue 24:00" when that is not the moment's day. + func at(_ ms: Double, asEnd: Bool, from t: Double) -> String { + let w = wall(ms, asEnd: asEnd) + guard w.day != day(t) else { return w.hm } + var named = calendar + named.locale = Words.locale + let weekday = named.component(.weekday, from: w.day) + return "\(named.shortWeekdaySymbols[weekday - 1]) \(w.hm)" + } + + /// "Cheapest 02–04", "Cheapest now until 04", "Cheapest tomorrow 15–17". + /// + /// Tomorrow is named only when the clock alone would read as today: at + /// 20:00, "02–04" can only be tonight, but at 14:00 "15–17" looks like + /// this afternoon. + func cheapest(_ block: PriceStrip.Block, from t: Double) -> String { + let a = wall(block.startMs, asEnd: false) + let b = wall(block.endMs, asEnd: true) + let whole = a.minute == 0 && b.minute == 0 + func short(_ w: Wall) -> String { whole ? String(format: "%02d", w.hour) : w.hm } + if block.startMs <= t { + return GlanceWord.cheapestNow.format(short(b)) + } + let range = "\(short(a))–\(short(b))" + return GlanceWord.cheapest.format(misleads(a, from: t) ? GlanceWord.tomorrow.format(range) : range) + } + + /// "cheapest 02:45", "cheapest now", "cheapest tomorrow 15:00". + func cheapestStart(_ block: PriceStrip.Block, from t: Double) -> String { + if block.startMs <= t { return GlanceWord.cheapestStartNow.text } + let a = wall(block.startMs, asEnd: false) + return GlanceWord.cheapestStart.format(misleads(a, from: t) ? GlanceWord.tomorrow.format(a.hm) : a.hm) + } + + /// A time tomorrow that the clock alone would read as later today. + private func misleads(_ a: Wall, from t: Double) -> Bool { + let now = wall(t, asEnd: false) + return a.day > now.day && (a.hour, a.minute) > (now.hour, now.minute) + } +} diff --git a/appleApp/FTWKit/Sources/FTWShared/PriceRefresh.swift b/appleApp/FTWKit/Sources/FTWShared/PriceRefresh.swift new file mode 100644 index 0000000..12dd383 --- /dev/null +++ b/appleApp/FTWKit/Sources/FTWShared/PriceRefresh.swift @@ -0,0 +1,35 @@ +import Foundation + +/// When the app asks iOS to wake it for tomorrow's prices. +/// +/// The day-ahead market publishes tomorrow around 12:45, and the box asks +/// it once an hour, so the box holds tomorrow some time between then and +/// about 13:45. The app asks to run just after 13:05, and again every half +/// hour until the box has tomorrow. iOS decides when, or whether, it runs: +/// this is the earliest, never a promise. +public enum PriceRefresh { + public static let hour = 13 + public static let minute = 5 + /// How soon to ask again when the box did not have tomorrow yet. + public static let retryMs: Double = 30 * 60_000 + /// No retries after this hour; the next try is tomorrow's. + public static let lastRetryHour = 20 + + /// The earliest the next refresh should run, given when the prices the + /// widgets hold run out. + public static func next(afterMs nowMs: Double, pricesEndMs: Double?, calendar: Calendar = .current) -> Double { + let now = Date(timeIntervalSince1970: nowMs / 1000) + let today = calendar.startOfDay(for: now) + func at(_ day: Int, _ h: Int, _ m: Int) -> Double { + let d = calendar.date(byAdding: .day, value: day, to: today) ?? today + let t = calendar.date(bySettingHour: h, minute: m, second: 0, of: d) ?? d + return t.timeIntervalSince1970 * 1000 + } + let publish = at(0, hour, minute) + if nowMs < publish { return publish } + let endOfTomorrow = at(2, 0, 0) + if let end = pricesEndMs, end >= endOfTomorrow { return at(1, hour, minute) } + let retry = nowMs + retryMs + return retry <= at(0, lastRetryHour, 0) ? retry : at(1, hour, minute) + } +} diff --git a/appleApp/FTWKit/Sources/FTWShared/PriceStrip.swift b/appleApp/FTWKit/Sources/FTWShared/PriceStrip.swift new file mode 100644 index 0000000..e04c0ac --- /dev/null +++ b/appleApp/FTWKit/Sources/FTWShared/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/FTWShared/PriceUnits.swift b/appleApp/FTWKit/Sources/FTWShared/PriceUnits.swift new file mode 100644 index 0000000..60847ba --- /dev/null +++ b/appleApp/FTWKit/Sources/FTWShared/PriceUnits.swift @@ -0,0 +1,50 @@ +import Foundation + +/// What to call the number on a price, from the box's price-units.js. Every +/// price is minor units per kWh; only the label and the decimals differ. +public enum PriceUnits { + public struct Unit: Sendable { + public let label: String + public let perKwh: String + public let scale: Double + public let decimals: Int + } + + static let units: [String: Unit] = [ + "SEK": Unit(label: "öre", perKwh: "öre/kWh", scale: 1, decimals: 1), + "NOK": Unit(label: "øre", perKwh: "øre/kWh", scale: 1, decimals: 1), + "DKK": Unit(label: "øre", perKwh: "øre/kWh", scale: 1, decimals: 1), + "EUR": Unit(label: "cent", perKwh: "cent/kWh", scale: 1, decimals: 1), + "PLN": Unit(label: "gr", perKwh: "gr/kWh", scale: 1, decimals: 1), + "CHF": Unit(label: "Rp.", perKwh: "Rp./kWh", scale: 1, decimals: 1), + "CZK": Unit(label: "Kč", perKwh: "Kč/kWh", scale: 0.01, decimals: 2), + "HUF": Unit(label: "Ft", perKwh: "Ft/kWh", scale: 0.01, decimals: 1), + "RON": Unit(label: "lei", perKwh: "lei/kWh", scale: 0.01, decimals: 2), + ] + + public static func unit(_ currency: String?) -> Unit { + let code = (currency ?? "SEK").isEmpty ? "SEK" : (currency ?? "SEK").uppercased() + return units[code] ?? Unit(label: code, perKwh: "\(code)/kWh", scale: 0.01, decimals: 3) + } + + public static func display(_ minor: Double, _ currency: String?) -> Double { + minor * unit(currency).scale + } + + /// "17.4", in the chart's unit and precision. + public static func text(_ minor: Double?, _ currency: String?) -> String? { + guard let minor, minor.isFinite else { return nil } + return PowerFormat.fixed(display(minor, currency), unit(currency).decimals) + } + + /// Whether a price window misses hours rather than ending early: a head + /// that starts after the window asked for, or a hole in the middle. + public static func hasHole(_ slots: [PriceSlot], fromMs: Double) -> Bool { + guard let first = slots.first else { return false } + if first.startMs > fromMs { return true } + for i in slots.indices.dropFirst() where slots[i - 1].startMs + slots[i - 1].durationMs < slots[i].startMs { + return true + } + return false + } +} diff --git a/appleApp/FTWKit/Sources/FTWShared/Prices.swift b/appleApp/FTWKit/Sources/FTWShared/Prices.swift new file mode 100644 index 0000000..20bbd9f --- /dev/null +++ b/appleApp/FTWKit/Sources/FTWShared/Prices.swift @@ -0,0 +1,34 @@ +import Foundation + +public struct PriceSlot: Equatable, Sendable, Identifiable, Codable { + public var startMs: Double + public var durationMs: Double + /// Integer minor units per kWh. Money never crosses as a float. + public var spotMinor: Double + /// What the household pays, tariff and tax included, computed by the box. + public var totalMinor: Double + public var id: Double { startMs } + + public init(startMs: Double, durationMs: Double, spotMinor: Double, totalMinor: Double) { + self.startMs = startMs + self.durationMs = durationMs + self.spotMinor = spotMinor + self.totalMinor = totalMinor + } +} + +public struct Prices: Equatable, Sendable, Codable { + public var zone: String + public var currency: String + public var slots: [PriceSlot] + /// The answer does not cover the window asked for. Which of the three + /// shapes it is has to be read off the slots. + public var stale: Bool + + public init(zone: String, currency: String, slots: [PriceSlot], stale: Bool) { + self.zone = zone + self.currency = currency + self.slots = slots + self.stale = stale + } +} diff --git a/appleApp/FTWKit/Sources/FTWShared/Readings.swift b/appleApp/FTWKit/Sources/FTWShared/Readings.swift new file mode 100644 index 0000000..7099d3b --- /dev/null +++ b/appleApp/FTWKit/Sources/FTWShared/Readings.swift @@ -0,0 +1,36 @@ +import Foundation + +// FTWShared is what the app shares with its widgets, and later with a +// notification extension. Every type here is a plain `Sendable` value with +// no main actor, so an extension can read it without the app's session. +// +// The names below are the registry's `carrier_states` and `source_states`, +// checked by `ContractTests`. + +/// How frames are reaching us. Orthogonal to `SourceState`, never merged +/// with it: "connected, but the inverter went quiet 40 seconds ago" needs +/// both. +public enum CarrierKind: String, Sendable, Codable { + case webrtc, relay, cache, none +} + +public enum SourceState: String, Sendable, Codable, Comparable { + case live, lagging, stale, down, never + + var rank: Int { + switch self { + case .live: return 0 + case .lagging: return 1 + case .stale: return 2 + case .down: return 3 + case .never: return 4 + } + } + + public static func < (a: SourceState, b: SourceState) -> Bool { a.rank < b.rank } +} + +/// Where the session is between a carrier and a stream. +public enum SessionPhase: String, Sendable { + case idle, handshaking, subscribing, streaming, booting, terminated, failed +} diff --git a/appleApp/FTWKit/Sources/FTWShared/Resources/en.lproj/Localizable.strings b/appleApp/FTWKit/Sources/FTWShared/Resources/en.lproj/Localizable.strings new file mode 100644 index 0000000..f430f6d --- /dev/null +++ b/appleApp/FTWKit/Sources/FTWShared/Resources/en.lproj/Localizable.strings @@ -0,0 +1,2 @@ +/* English is the key in Words.text, so this table stays empty. It exists so + the bundle lists English as a language of its own. */ diff --git a/appleApp/FTWKit/Sources/FTWShared/Resources/sv.lproj/Localizable.strings b/appleApp/FTWKit/Sources/FTWShared/Resources/sv.lproj/Localizable.strings new file mode 100644 index 0000000..2fe7e81 --- /dev/null +++ b/appleApp/FTWKit/Sources/FTWShared/Resources/sv.lproj/Localizable.strings @@ -0,0 +1,48 @@ +/* Swedish for FTWShared. The key is the English sentence. */ + +/* Freshness band */ +"Live at home" = "Live hemma"; +"Live via encrypted relay" = "Live via krypterat relä"; +"Home link connected · no reading yet" = "Hemlänk ansluten · inget mätvärde än"; +"Home link connected · a device stopped responding" = "Hemlänk ansluten · en enhet svarar inte"; +"Home link connected · readings" = "Hemlänk ansluten · mätvärden"; +"Encrypted relay connected · no reading yet" = "Krypterat relä anslutet · inget mätvärde än"; +"Encrypted relay connected · a device stopped responding" = "Krypterat relä anslutet · en enhet svarar inte"; +"Encrypted relay connected · readings" = "Krypterat relä anslutet · mätvärden"; +"Access ended" = "Åtkomsten är avslutad"; +"FTW is busy" = "FTW är upptaget"; +"Your box is starting" = "Din box startar"; +"Reconnecting to your box" = "Ansluter till din box igen"; +"Waiting for live readings" = "Väntar på mätvärden"; +"Securing encrypted relay" = "Krypterar anslutningen"; +"Securing home link" = "Krypterar hemlänken"; +"Connecting to your box" = "Ansluter till din box"; +"Can't reach your box" = "Når inte din box"; + +/* Age of a reading */ +"unknown" = "okänt"; +"just now" = "nyss"; +"%lds ago" = "för %ld s sedan"; +"%ld min ago" = "för %ld min sedan"; +"%ld h ago" = "för %ld h sedan"; +"%ld d ago" = "för %ld d sedan"; + +/* Price widgets */ +"Electricity price" = "Elpris"; +"The price now and when it is cheapest next, from what FTW last fetched." = "Priset nu och när det blir billigast härnäst, från det FTW senast hämtade."; +"Price now" = "Pris nu"; +"Cheapest %@" = "Billigast %@"; +"Cheapest now until %@" = "Billigast nu till %@"; +"tomorrow %@" = "i morgon %@"; +"cheapest %@" = "billigast %@"; +"cheapest now" = "billigast nu"; +"No prices after %@ · open FTW" = "Inga priser efter %@ · öppna FTW"; +"No price until %@ · open FTW" = "Inget pris förrän %@ · öppna FTW"; +"Open FTW to see prices" = "Öppna FTW för att se priser"; +"No prices · open FTW" = "Inga priser · öppna FTW"; +"Fetched %@" = "Hämtat %@"; +"incl. grid fee and VAT" = "inkl. nätavgift och moms"; +"incl. VAT" = "inkl. moms"; +"incl. grid fee" = "inkl. nätavgift"; +"spot price only" = "endast spotpris"; +"incl. surcharges" = "inkl. påslag"; diff --git a/appleApp/FTWKit/Sources/FTWShared/Words.swift b/appleApp/FTWKit/Sources/FTWShared/Words.swift new file mode 100644 index 0000000..2cb7302 --- /dev/null +++ b/appleApp/FTWKit/Sources/FTWShared/Words.swift @@ -0,0 +1,25 @@ +import Foundation + +/// The words FTWShared puts on screen, in English or Swedish. +/// +/// The English sentence is the key, so a missing translation shows English +/// rather than a code. Swedish lives in `Resources/sv.lproj`. The app and +/// the widget list both languages in `CFBundleLocalizations`; without that, +/// iOS shows a package in the app's development language only. +enum Words { + static var bundle: Bundle { .module } + + static func text(_ key: String) -> String { + bundle.localizedString(forKey: key, value: key, table: nil) + } + + static func format(_ key: String, _ args: CVarArg...) -> String { + String(format: text(key), arguments: args) + } + + /// The language the words resolve to, for the names the system spells, + /// such as weekdays, so a Swedish sentence never names an English day. + static var locale: Locale { + Locale(identifier: Bundle.module.preferredLocalizations.first ?? "en") + } +} diff --git a/appleApp/FTWKit/Tests/FTWKitTests/AboutTests.swift b/appleApp/FTWKit/Tests/FTWKitTests/AboutTests.swift new file mode 100644 index 0000000..00d3dd9 --- /dev/null +++ b/appleApp/FTWKit/Tests/FTWKitTests/AboutTests.swift @@ -0,0 +1,202 @@ +import Foundation +import Testing +@testable import FTWKit + +/// Source & licenses and removing this phone: what the license asks the app +/// to offer, and the words it uses to offer it. +@Suite struct AboutTests { + static let sha = "5a14ff0960d99fa0ca982c82fb2b7c1d5e213d25" + + // MARK: The build's source + + @Test func aCleanCommitPinsBothLinks() { + let source = BuildSource(version: "0.1.0", build: "7", recorded: Self.sha + "\n") + #expect(source.revision == .commit(Self.sha)) + #expect(source.sourceURL.absoluteString == "https://github.com/srcfl/ftw-app/tree/\(Self.sha)") + #expect(source.licenseURL.absoluteString == "https://github.com/srcfl/ftw-app/blob/\(Self.sha)/LICENSE") + #expect(source.documentBaseURL.absoluteString == "https://github.com/srcfl/ftw-app/blob/\(Self.sha)/") + #expect(source.versionText == "0.1.0 (7)") + #expect(source.revisionText == "5a14ff0") + #expect(source.localNote == nil) + } + + @Test func uncommittedChangesAreALocalBuildOnTheirBase() { + let source = BuildSource(version: "0.1.0", build: "1", recorded: Self.sha + "-dirty") + #expect(source.revision == .local(base: Self.sha)) + #expect(source.isLocal) + #expect(source.sourceURL == Origin.sourceURL) + #expect(source.licenseURL.absoluteString == "https://github.com/srcfl/ftw-app/blob/main/LICENSE") + AboutWords.$language.withValue(.en) { + #expect(source.revisionText == "Local build") + #expect(source.localNote?.contains("5a14ff0") == true) + } + AboutWords.$language.withValue(.sv) { + #expect(source.revisionText == "Lokalt bygge") + } + } + + /// Only 40 lower-case hex digits name a commit. Anything else, including + /// nothing at all, links to the repository and says so. + @Test(arguments: [nil, "", "dev", "5a14ff0", Self.sha.uppercased(), Self.sha + "0", "g" + Self.sha.dropFirst(), "-dirty"]) + func anythingElseIsALocalBuild(_ recorded: String?) { + let source = BuildSource(version: "0.1.0", build: "1", recorded: recorded) + #expect(source.revision == .local(base: nil)) + #expect(source.sourceURL == Origin.sourceURL) + #expect(source.localNote != nil) + } + + /// App Review asks for the privacy policy inside the app. The pages are + /// srcfl/ftw-web's, in English and Swedish. + @Test func privacyAndSupportFollowTheLanguage() { + #expect(Origin.privacyURL(.en).absoluteString == "https://ftw.sourceful.energy/privacy") + #expect(Origin.privacyURL(.sv).absoluteString == "https://ftw.sourceful.energy/sv/privacy") + #expect(Origin.supportURL(.en).absoluteString == "https://ftw.sourceful.energy/support") + #expect(Origin.supportURL(.sv).absoluteString == "https://ftw.sourceful.energy/sv/support") + AboutWords.$language.withValue(.sv) { + #expect(Origin.privacyURL() == Origin.privacyURL(.sv)) + } + } + + // MARK: The license files + + /// Joining lines must never change the text: every word of every file, + /// in order, and nothing else. + @Test(arguments: LicenseText.Document.allCases) + func reflowKeepsEveryWord(_ document: LicenseText.Document) throws { + let text = try RepoFiles.read(document.rawValue) + let blocks = LicenseText.blocks(text, markdown: document.isMarkdown) + let original = text.split(whereSeparator: \.isNewline) + .map { $0.trimmingCharacters(in: .whitespaces) } + .filter { !LicenseText.isRule($0) } + .map { document.isMarkdown ? $0.replacingOccurrences(of: #"^#{1,6} "#, with: "", options: .regularExpression) : $0 } + .flatMap { $0.split(whereSeparator: \.isWhitespace) } + let shown = blocks.flatMap { $0.text.split(whereSeparator: \.isWhitespace) } + #expect(shown == original) + #expect(!blocks.isEmpty) + } + + @Test func theLicenseReadsAsParagraphsAndSections() throws { + let blocks = LicenseText.blocks(try RepoFiles.read("LICENSE"), markdown: false) + // The heading over the file stays two lines. + #expect(blocks.first?.text == "FTW native app\nCopyright 2026 Sourceful Labs AB and contributors.") + // A wrapped paragraph becomes one line the screen wraps itself. + let opening = try #require(blocks.first { $0.text.hasPrefix("This version of the project, as a whole") }) + #expect(!opening.text.contains("\n")) + #expect(blocks.contains { $0.kind == .heading(2) && $0.text == "0. Definitions." }) + #expect(blocks.contains { $0.kind == .heading(1) && $0.text == "TERMS AND CONDITIONS" }) + #expect(blocks.contains { $0.kind == .heading(1) && $0.text == "GNU AFFERO GENERAL PUBLIC LICENSE\nVersion 3, 19 November 2007" }) + #expect(blocks.contains { $0.kind == .rule }) + // Both of the license's own permissions are there. + #expect(blocks.contains { $0.text.contains("FTW app store distribution permission") } || blocks.contains { $0.text.contains("Energyplan") }) + // A list keeps one item to a line. + let items = blocks.filter { $0.text.contains("\n- ") } + for block in items { + for line in block.text.split(separator: "\n").dropFirst() { + #expect(!line.hasPrefix(" ")) + } + } + } + + @Test func licensingMarkdownHasItsHeadings() throws { + let blocks = LicenseText.blocks(try RepoFiles.read("LICENSING.md"), markdown: true) + #expect(blocks.first == LicenseText.Block(id: 0, kind: .heading(1), text: "FTW native app licensing")) + #expect(blocks.contains { $0.kind == .heading(2) && $0.text == "Source for a distributed version" }) + #expect(!blocks.contains { $0.text.hasPrefix("#") }) + } + + @Test func relativeLinksOpenTheFileAtTheBuildsCommit() throws { + let source = BuildSource(version: "0.1.0", build: "1", recorded: Self.sha) + let text = LicenseText.inline("See [LICENSE](LICENSE) and [the vision](https://github.com/srcfl/ftw/blob/master/VISION.md).", base: source.documentBaseURL) + let links = text.runs.compactMap(\.link).map(\.absoluteString) + #expect(links == ["https://github.com/srcfl/ftw-app/blob/\(Self.sha)/LICENSE", "https://github.com/srcfl/ftw/blob/master/VISION.md"]) + #expect(String(text.characters) == "See LICENSE and the vision.") + } + + @Test func listsAndShortLinesKeepTheirBreaks() { + let text = """ + All other conditions of the GNU AGPL remain in force. In particular: + + - Offer the Corresponding Source of the exact version you convey, under the + GNU AGPL and at no charge, outside the service and free of its terms. + - Make the license text and that source offer reachable from within the + program. + """ + let blocks = LicenseText.blocks(text, markdown: false) + #expect(blocks.count == 2) + #expect(blocks[1].text == "- Offer the Corresponding Source of the exact version you convey, under the GNU AGPL and at no charge, outside the service and free of its terms.\n- Make the license text and that source offer reachable from within the program.") + } + + // MARK: Third-party code + + /// Source & licenses says FTW carries no third-party code. That stays + /// true only while neither the package nor the project pulls any in; add + /// one and this fails until the screen lists it with its license. + @Test func thereIsNoThirdPartyCode() throws { + let package = try RepoFiles.read("appleApp/FTWKit/Package.swift") + #expect(!package.contains(".package(")) + let project = try RepoFiles.read("appleApp/FTW.xcodeproj/project.pbxproj") + #expect(!project.contains("XCRemoteSwiftPackageReference")) + } + + // MARK: Removing this phone + + @Test func theBoxNamesThePhoneByItsKey() throws { + let vectors = try IdentityVectors.load() + let name = PhoneRemoval.nameOnBox(vectors.enrollment.deviceIdOnBox) + #expect(name == "Phone \(vectors.enrollment.deviceIdOnBox)") + AboutWords.$language.withValue(.en) { + #expect(PhoneRemoval.boxSteps("abcd1234").last == "Choose Remove next to Phone abcd1234.") + } + AboutWords.$language.withValue(.sv) { + #expect(PhoneRemoval.boxSteps("abcd1234").last == "Välj Remove bredvid Phone abcd1234.") + } + } + + @MainActor + @Test func theNameOnTheBoxIsTheVaultsOwn() async throws { + let store = MemoryStore() + let vault = Vault(store: store) + #expect(vault.deviceIDOnBox == nil) + _ = try vault.deviceKey(vault.localWrappingKey()) + let id = try #require(vault.deviceIDOnBox) + #expect(id.count == Vault.boxDeviceIDChars) + #expect(PhoneRemoval.nameOnBox(id).hasSuffix(id)) + } + + // MARK: Words + + /// Every sentence the About screens hand to `AboutWords` has a Swedish + /// entry. Read from the source, so a new sentence cannot slip through. + @Test func everySentenceHasSwedish() throws { + let files = [ + "appleApp/FTWKit/Sources/FTWKit/Identity/BuildSource.swift", + "appleApp/FTWKit/Sources/FTWKit/Identity/LicenseText.swift", + "appleApp/FTWKit/Sources/FTWKit/Identity/PhoneRemoval.swift", + ] + (try RepoFiles.list("appleApp/FTW/About")) + let pattern = #/AboutWords\.(?:text|format)\("((?:[^"\\]|\\.)*)"/# + var seen = 0 + for file in files { + let source = try RepoFiles.read(file) + for match in source.matches(of: pattern) { + let english = String(match.1).replacingOccurrences(of: "\\\"", with: "\"") + seen += 1 + #expect(AboutWords.sv[english] != nil, "no Swedish for \"\(english)\" in \(file)") + } + } + #expect(seen > 20) + for (english, swedish) in AboutWords.sv { + #expect(!swedish.isEmpty) + #expect(english.components(separatedBy: "%@").count == swedish.components(separatedBy: "%@").count, "\(english)") + } + } +} + +extension RepoFiles { + /// The Swift files in a folder of the repository, as paths from its root. + static func list(_ folder: String) throws -> [String] { + try FileManager.default.contentsOfDirectory(atPath: root.appendingPathComponent(folder).path) + .filter { $0.hasSuffix(".swift") } + .sorted() + .map { "\(folder)/\($0)" } + } +} diff --git a/appleApp/FTWKit/Tests/FTWKitTests/AppModelTests.swift b/appleApp/FTWKit/Tests/FTWKitTests/AppModelTests.swift new file mode 100644 index 0000000..2ed611c --- /dev/null +++ b/appleApp/FTWKit/Tests/FTWKitTests/AppModelTests.swift @@ -0,0 +1,290 @@ +import Foundation +import Testing +@testable import FTWKit +import FTWDemo + +@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", demo: SimulatedBox.loopback) + 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 pricesSavingsAndStatusFill() async { + let (_, home, s) = await demo() + home.prices.activate() + home.savings.activate() + home.status.activate() + await run(s, 3_000) + #expect(home.prices.prices?.currency == "SEK") + #expect(home.savings.periods?.week.available == true) + #expect(home.status.fresh) + #expect(Flow.fuse(status: home.status.status!) != nil) + } + + @Test func leavingTheDemoTouchesNoSavedHome() async { + let (app, _, _) = await demo() + app.exitDemo() + #expect(app.home == nil) + #expect(!app.isDemo) + } + + /// A build without FTWDemo has no simulated home to open. + @Test func noDemoWithoutTheDemoModule() { + let app = AppModel(store: MemoryStore(), files: nil, passkeys: nil, scheduler: ManualScheduler(), build: "test", ua: "test") + #expect(!app.canDemo) + app.startDemo() + #expect(app.home == nil) + #expect(!app.isDemo) + } + + /// The demo writes nothing a widget could mistake for a home. + @Test func theDemoLeavesNothingForWidgets() async throws { + let scheduler = ManualScheduler() + let shared = AppGroupFiles(directory: URL(fileURLWithPath: NSTemporaryDirectory()).appendingPathComponent("ftw-shared-\(UUID().uuidString)")) + let app = AppModel(store: MemoryStore(), files: nil, shared: shared, passkeys: nil, scheduler: scheduler, build: "test", ua: "test", demo: SimulatedBox.loopback) + app.startDemo() + let home = try #require(app.home) + home.prices.activate() + home.charging.activate() + await run(scheduler, 7_000) + #expect(home.prices.prices != nil) + #expect(shared.siteIDs(PriceEntry.self).isEmpty) + #expect(shared.siteIDs(CarEntry.self).isEmpty) + } +} + +@MainActor +@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 passkeys = FakePasskeys() + let shared = AppGroupFiles(directory: URL(fileURLWithPath: NSTemporaryDirectory()).appendingPathComponent("ftw-shared-\(UUID().uuidString)")) + let widgetWakes = Counter() + + init() { + files = SealedFiles(directory: URL(fileURLWithPath: NSTemporaryDirectory()).appendingPathComponent("ftw-test-\(UUID().uuidString)"), store: store) + box = SimulatedBox(scheduler: scheduler) + endpoint = NoiseBoxEndpoint(box: box) + relay = FakeRelay(scheduler: scheduler, endpoint: endpoint) + } + + func app() -> AppModel { + AppModel(store: store, files: files, shared: shared, sharedWritten: { [widgetWakes] in widgetWakes.count += 1 }, passkeys: passkeys, scheduler: scheduler, build: "test", ua: "test", relayURL: URL(string: "wss://relay.test")!, makeSocket: relay.factory) + } + + var pairingURL: String { + 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) + + // 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 aConfigureWriteGoesThrough() async throws { + let world = World() + let app = world.app() + let pair = PairModel(app: app) + let t = Task { await pair.pair(world.pairingURL) } + await world.run(500) + await t.value + let site = try #require(app.site) + let task = Task { try await site.callBox(.put, "/api/loadpoints/garage/schedule", body: ["soc": .number(0.7), "time_of_day_min_utc": 300, "recurring": true, "days": 0, "surplus_unlock_bat_soc": 0]) } + await world.run(300) + _ = try await task.value + #expect(world.box.house.schedule?.socFraction == 0.7) + } + + @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 widgets lose their records and are told. + #expect(world.shared.siteIDs(PriceEntry.self).isEmpty) + #expect(world.widgetWakes.count > 0) + } + + /// iOS freezes a backgrounded app within seconds. The session closes + /// first, freeing one of the relay's four streams, and a new one opens + /// on return without a passkey. + @Test func theBackgroundClosesTheSessionAndTheForegroundReopensIt() async throws { + let world = World() + let app = world.app() + let pair = PairModel(app: app) + let t = Task { await pair.pair(world.pairingURL) } + await world.run(500) + await t.value + let site = try #require(app.site) + #expect(site.session.phase == .streaming) + + app.setVisible(false) + app.suspend() + await world.run(100) + #expect(!site.core.hasCarrier) + #expect(world.relay.current == nil) + #expect(site.carrier == .none) + // What was on screen stays, dated. + #expect(site.session.fields[Contract.FID.gridW] != nil) + #expect(!site.isLive) + + app.setVisible(true) + await world.run(1_000) + #expect(site.session.phase == .streaming) + #expect(site.isLive || site.srcState == .live) + #expect(world.relay.dialled.count == 2) + #expect(world.passkeys.assertions.isEmpty) + } + + /// Widgets read what the app leaves in the App Group, never the relay. + @Test func widgetsGetThePricesAndTheCar() async throws { + let world = World() + let app = world.app() + let pair = PairModel(app: app) + let t = Task { await pair.pair(world.pairingURL) } + await world.run(500) + await t.value + let home = try #require(app.home) + let siteId = try #require(home.site.siteId) + home.prices.activate() + home.charging.activate() + await world.run(6_000, step: 100) + + let reader = CacheReader(sources: [world.shared]) + #expect(reader.latestSiteID(PriceEntry.self) == siteId) + let prices = try #require(reader.read(PriceEntry.self, siteId: siteId)) + #expect(prices.value.prices.currency == "SEK") + #expect(!prices.value.prices.slots.isEmpty) + // It arrived over the relay, and reads back as the cache. + #expect(prices.record.carrier == .relay) + #expect(prices.carrier == .cache) + let car = try #require(reader.read(CarEntry.self, siteId: siteId)?.value.cars.first) + #expect(car.loadpointID == "garage") + #expect(car.socSource == world.box.house.carSocSource) + #expect(car.socReadAtMs != nil) + #expect(world.widgetWakes.count >= 2) + + // The same car five seconds later is not written again. + let wakes = world.widgetWakes.count + await world.run(6_000, step: 100) + #expect(world.widgetWakes.count == wakes) + } +} + +@MainActor +final class Counter { + var count = 0 +} diff --git a/appleApp/FTWKit/Tests/FTWKitTests/CarTests.swift b/appleApp/FTWKit/Tests/FTWKitTests/CarTests.swift new file mode 100644 index 0000000..5c106a8 --- /dev/null +++ b/appleApp/FTWKit/Tests/FTWKitTests/CarTests.swift @@ -0,0 +1,654 @@ +import Foundation +import Testing +@testable import FTWKit +import FTWDemo + +/// The car view's rules: where a level comes from and how old it is, the +/// receipt under every command, the ready time the box really uses, and +/// whether the car makes it. In English unless a test says otherwise. +@Suite struct CarTextTests { + static let utc = TimeZone(identifier: "UTC")! + static let stockholm = TimeZone(identifier: "Europe/Stockholm")! + + func lp(_ json: String) throws -> Loadpoint { + Loadpoint(try JSON(parsing: Array(json.utf8))) + } + + /// 2026-10-12 10:00:00 UTC. + static let t: Double = 1_791_799_200_000 + + // MARK: The level's origin and age + + @Test func theLevelSaysWhoSaidIt() throws { + try AppWords.$language.withValue(.en) { + let t = Self.t + let assumed = try lp(#"{"id":"a","plugged_in":true,"current_soc":0.2,"soc_source":"assumed"}"#) + let a = try #require(EVText.level(assumed, answeredAtMs: t, nowMs: t)) + #expect(a.origin == .assumed) + #expect(a.pct == 20) + #expect(EVText.origin(a, zone: Self.utc) == "FTW assumes 20\u{00A0}%. Nobody has told it the real level.") + #expect(EVText.originShort(a, zone: Self.utc) == "FTW assumes") + + let estimated = try lp(#"{"id":"a","plugged_in":true,"current_soc":0.46,"soc_source":"inferred"}"#) + #expect(EVText.level(estimated, answeredAtMs: t, nowMs: t)?.origin == .estimated) + + // Set from this phone: said as such, and once the box has added + // what charged since, both numbers are honest. + let set = EVText.LevelSet(pct: 40, atMs: t - 600_000) + let you = try #require(EVText.level(estimated, answeredAtMs: t, nowMs: t, set: set)) + #expect(you.origin == .you) + #expect(you.pct == 46) + #expect(EVText.origin(you, zone: Self.utc) == "You set 40\u{00A0}% at 09:50. FTW adds what has charged since.") + let same = try #require(EVText.level(try lp(#"{"id":"a","plugged_in":true,"current_soc":0.4,"soc_source":"inferred"}"#), answeredAtMs: t, nowMs: t, set: set)) + #expect(EVText.origin(same, zone: Self.utc) == "You set 40\u{00A0}% at 09:50") + + let completed = try lp(#"{"id":"a","plugged_in":true,"current_soc":0.8,"soc_source":"completed"}"#) + #expect(EVText.level(completed, answeredAtMs: t, nowMs: t)?.origin == .unconfirmed) + + // No car on the cable: no level, and none offered. + #expect(EVText.level(try lp(#"{"id":"a","plugged_in":false,"current_soc":0.6}"#), answeredAtMs: t, nowMs: t) == nil) + } + } + + /// Core plans from a car's report for five minutes. Older than that the + /// view still shows it, dated "last reported HH:MM". + @Test func aCarsLevelOlderThanFiveMinutesIsDated() throws { + try AppWords.$language.withValue(.en) { + let t = Self.t + let fresh = try lp(#"{"id":"a","plugged_in":true,"current_soc":0.6,"soc_source":"vehicle","vehicle_driver":"vag","vehicle_soc":0.64,"vehicle_soc_age_s":300}"#) + let f = try #require(EVText.level(fresh, answeredAtMs: t, nowMs: t)) + #expect(f.origin == .car) + #expect(f.pct == 64) + #expect(EVText.origin(f, zone: Self.utc) == "From the car") + + let old = try lp(#"{"id":"a","plugged_in":true,"current_soc":0.6,"soc_source":"inferred","vehicle_driver":"vag","vehicle_soc":0.64,"vehicle_soc_age_s":301}"#) + let o = try #require(EVText.level(old, answeredAtMs: t, nowMs: t)) + #expect(o.origin == .carEarlier) + #expect(o.atMs == t - 301_000) + #expect(EVText.origin(o, zone: Self.utc) == "From the car, last reported 09:54") + #expect(EVText.originShort(o, zone: Self.utc) == "reported 09:54") + + // The same answer ages on screen: two minutes later it is dated. + #expect(EVText.level(fresh, answeredAtMs: t, nowMs: t + 120_000)?.origin == .carEarlier) + + // The box's own stale flag dates it whatever the age. + let flagged = try lp(#"{"id":"a","plugged_in":true,"soc_source":"inferred","vehicle_driver":"vag","vehicle_soc":0.5,"vehicle_soc_age_s":10,"vehicle_stale":true}"#) + #expect(EVText.level(flagged, answeredAtMs: t, nowMs: t)?.origin == .carEarlier) + + // A box without vehicle_soc still dates a vehicle level by its age. + let legacy = try lp(#"{"id":"a","plugged_in":true,"current_soc":0.6,"soc_source":"vehicle","vehicle_soc_age_s":900}"#) + #expect(EVText.level(legacy, answeredAtMs: t, nowMs: t)?.origin == .carEarlier) + + // A level set here after the car's last report wins. + let later = EVText.LevelSet(pct: 70, atMs: t - 60_000) + #expect(EVText.level(old, answeredAtMs: t, nowMs: t, set: later)?.origin == .you) + let earlier = EVText.LevelSet(pct: 70, atMs: t - 900_000) + #expect(EVText.level(old, answeredAtMs: t, nowMs: t, set: earlier)?.origin == .carEarlier) + } + } + + @Test func theLevelInSwedish() throws { + try AppWords.$language.withValue(.sv) { + let t = Self.t + let assumed = try #require(EVText.level(try lp(#"{"id":"a","plugged_in":true,"current_soc":0.2,"soc_source":"assumed"}"#), answeredAtMs: t, nowMs: t)) + #expect(EVText.origin(assumed, zone: Self.utc) == "FTW antar 20\u{00A0}%. Ingen har angett den riktiga nivån.") + let old = try #require(EVText.level(try lp(#"{"id":"a","plugged_in":true,"soc_source":"inferred","vehicle_driver":"vag","vehicle_soc":0.64,"vehicle_soc_age_s":2400}"#), answeredAtMs: t, nowMs: t)) + #expect(EVText.origin(old, zone: Self.stockholm) == "Från bilen, senast rapporterad 11:20") + let you = EVText.Level(pct: 40, origin: .you, atMs: t, setPct: nil) + #expect(EVText.origin(you, zone: Self.stockholm) == "Du angav 40\u{00A0}% kl. 12:00") + #expect(EVText.status(try lp(#"{"id":"a","plugged_in":true,"current_power_w":7200}"#)) == "Laddar med 7,2\u{00A0}kW") + } + } + + @Test func plusAndMinusMoveByFive() { + #expect(EVText.stepped(41, up: true) == 45) + #expect(EVText.stepped(41, up: false) == 40) + #expect(EVText.stepped(40, up: true) == 45) + #expect(EVText.stepped(40, up: false) == 35) + #expect(EVText.stepped(100, up: true) == 100) + #expect(EVText.stepped(0, up: false) == 0) + } + + // MARK: The outcome row + + @Test func theReceiptFollowsAckAndResult() throws { + try AppWords.$language.withValue(.en) { + let sending = EVText.outcome(EVText.Step(ask: .level(40)), nil) + #expect(sending.sent == .done && sending.received == .active && sending.done == .waiting) + #expect(sending.tone == .working) + #expect(sending.text == "Sending 40\u{00A0}% to FTW…") + + let received = EVText.outcome(EVText.Step(ask: .level(40), acked: true), nil) + #expect(received.received == .done && received.done == .active) + #expect(received.text == "FTW received 40\u{00A0}%.") + + let done = EVText.outcome(EVText.Step(ask: .level(40), acked: true, result: .applied(observed: 0.4)), nil) + #expect(done.received == .done && done.done == .done && done.tone == .done) + #expect(done.text == "FTW now plans from 40\u{00A0}%.") + + let superseded = EVText.outcome(EVText.Step(ask: .level(40), acked: true, result: .superseded(observed: 0.45)), nil) + #expect(superseded.done == .failed && superseded.tone == .warning) + #expect(superseded.text == "FTW kept 45\u{00A0}%. A newer level came first.") + + let unconfirmed = EVText.outcome(EVText.Step(ask: .chargeNow, acked: true, result: .unconfirmed(note: nil)), nil) + #expect(unconfirmed.done == .waiting && unconfirmed.tone == .warning) + #expect(unconfirmed.text == "FTW received it but has not confirmed the result yet.") + + // Refused after the box took it, and never reaching it at all. + let why = EVText.refusal(CmdResult(cmdId: "c", state: .rejected, errorCode: "E_UNAVAILABLE", errorArgs: ["reason": .text("unplugged")])) + #expect(why == "Plug the car in first. FTW takes a level only while the car is plugged in.") + let refused = EVText.outcome(EVText.Step(ask: .level(40), acked: true, result: .refused(why)), nil) + #expect(refused.received == .done && refused.done == .failed && refused.tone == .failed) + #expect(refused.text == why) + let lost = EVText.outcome(EVText.Step(ask: .chargeNow, result: .refused(EVText.failure(CommandError(code: "E_NO_ACK", help: "")))), nil) + #expect(lost.received == .failed && lost.done == .failed) + #expect(lost.text == "Did not reach FTW. Try again.") + #expect(EVText.failure(SessionError.noCarrier) == "Not connected to FTW right now. Try again when the connection is back.") + + // Done for Charge now means the box holds it; the charger's own + // account says whether the car takes it. + let started = EVText.Step(ask: .chargeNow, acked: true, result: .applied(observed: 11_000)) + #expect(EVText.outcome(started, nil).text == "FTW started charging. Waiting for the car to draw power.") + let drawing = try lp(#"{"id":"a","plugged_in":true,"manual_active":true,"manual_charge_w":11000,"current_power_w":11000,"manual":{"state":"charging"}}"#) + #expect(EVText.outcome(started, drawing).text == "Charging at 11\u{00A0}kW. The plan waits until you go back to it.") + let refusing = try lp(#"{"id":"a","plugged_in":true,"manual_active":true,"manual_charge_w":11000,"manual":{"state":"not_drawing"}}"#) + #expect(EVText.outcome(started, refusing).text == "The car is not taking power. Check the car's charge limit.") + #expect(EVText.outcome(EVText.Step(ask: .resume, acked: true, result: .applied(observed: 0)), nil).text == "Back to the plan. FTW decides when to charge.") + } + } + + @Test func theReceiptInSwedish() { + AppWords.$language.withValue(.sv) { + let r = EVText.outcome(EVText.Step(ask: .chargeNow, acked: true), nil) + #expect(r.text == "FTW har tagit emot det.") + #expect(EVText.outcome(EVText.Step(ask: .pause, acked: true, result: .applied(observed: 0)), nil).text == "Pausad. Bilen laddar inte förrän du väljer.") + #expect(AppWords.text("Sent") == "Skickat") + #expect(AppWords.text("Received") == "Mottaget") + #expect(AppWords.text("Done") == "Utfört") + } + } + + // MARK: The ready time over 25 October 2026 + + static func noon(_ day: Int, _ month: Int = 10) -> Date { + var cal = Calendar(identifier: .gregorian) + cal.timeZone = stockholm + return cal.date(from: DateComponents(year: 2026, month: month, day: day, hour: 12))! + } + + static func utcDate(_ s: String) -> Date { ISO8601DateFormatter().date(from: s)! } + + /// Summer time ends on 25 October 2026. 07:00 is 05:00 UTC before and + /// 06:00 UTC after; the box keeps 07:00 and answers with today's minute. + @Test func theReadyTimeIsTheHouseholdClockAcrossOctober25() throws { + let z = Self.stockholm + #expect(EVText.minuteUTC(clock: 420, at: Self.noon(24), zone: z) == 300) + #expect(EVText.minuteUTC(clock: 420, at: Self.noon(25), zone: z) == 360) + #expect(EVText.minuteUTC(clock: 420, at: Self.noon(26), zone: z) == 360) + #expect(EVText.clockFromUTC(300, at: Self.noon(24), zone: z) == 420) + #expect(EVText.clockFromUTC(360, at: Self.noon(25), zone: z) == 420) + #expect(EVText.clockFromUTC(360, at: Self.noon(26), zone: z) == 420) + // A box that kept the UTC minute really charges by 06:00 after the + // change, and the view says 06:00 rather than the 07:00 once asked. + #expect(EVText.clockFromUTC(300, at: Self.noon(26), zone: z) == 360) + + // The hour that happens twice counts its first pass; the hour that + // never happens (29 March) gives the end of the gap. Core does both. + #expect(EVText.minuteUTC(clock: 150, at: Self.noon(25), zone: z) == 30) + #expect(EVText.minuteUTC(clock: 150, at: Self.noon(29, 3), zone: z) == 60) + } + + @Test func theGoalShowsTheWallTimeTheBoxUses() throws { + try AppWords.$language.withValue(.en) { + let z = Self.stockholm + let before = try lp(#"{"id":"a","plugged_in":true,"schedule":{"soc":0.8,"time_of_day_min_utc":300,"recurring":true,"days":31}}"#) + let after = try lp(#"{"id":"a","plugged_in":true,"schedule":{"soc":0.8,"time_of_day_min_utc":360,"recurring":true,"days":31}}"#) + let clocked = try lp(#"{"id":"a","plugged_in":true,"schedule":{"soc":0.8,"time_of_day_min_utc":300,"time_of_day_min":420,"recurring":true,"days":31}}"#) + #expect(EVText.goal(try #require(before.schedule), at: Self.noon(24), zone: z) == "80\u{00A0}% by 07:00 on weekdays") + #expect(EVText.goal(try #require(after.schedule), at: Self.noon(26), zone: z) == "80\u{00A0}% by 07:00 on weekdays") + // A box that sends the household clock is read as it says. + #expect(EVText.goal(try #require(clocked.schedule), at: Self.noon(26), zone: z) == "80\u{00A0}% by 07:00 on weekdays") + + // From Saturday noon the next weekday 07:00 is Monday, in winter time. + let next = EVText.nextDeadlineMs(try #require(before.schedule), nowMs: Self.noon(24).timeIntervalSince1970 * 1000, zone: z) + #expect(next == Self.utcDate("2026-10-26T06:00:00Z").timeIntervalSince1970 * 1000) + var daily = try #require(before.schedule) + daily.days = 0 + let sunday = EVText.nextDeadlineMs(daily, nowMs: Self.noon(24).timeIntervalSince1970 * 1000, zone: z) + #expect(sunday == Self.utcDate("2026-10-25T06:00:00Z").timeIntervalSince1970 * 1000) + } + AppWords.$language.withValue(.sv) { + let s = Loadpoint.Schedule(socPct: 80, timeOfDayMinUTC: 300, clockMin: nil, surplusUnlockPct: 0, recurring: true, days: 0b0011111, finishAtVehicleLimit: false) + #expect(EVText.goal(s, at: Self.noon(24), zone: Self.stockholm) == "80\u{00A0}% till 07:00 vardagar") + } + } + + @Test func theHouseholdZoneIsTheBoxsWhenItNamesOne() throws { + let phone = TimeZone(identifier: "America/New_York")! + #expect(EVText.householdZone(boxTZ: "Europe/Stockholm", phone: phone) == Self.stockholm) + #expect(EVText.householdZone(boxTZ: "Local", phone: phone) == phone) + #expect(EVText.householdZone(boxTZ: "UTC", phone: phone) == phone) + #expect(EVText.householdZone(boxTZ: "", phone: phone) == phone) + #expect(EVText.householdZone(boxTZ: "Not/AZone", phone: phone) == phone) + try AppWords.$language.withValue(.en) { + let away = try lp(#"{"id":"a","plugged_in":true,"schedule":{"soc":0.8,"time_of_day_min_utc":300,"recurring":true,"days":0}}"#) + let now = Self.noon(24).timeIntervalSince1970 * 1000 + #expect(EVText.nextGoal(away, nowMs: now, zone: Self.stockholm, phone: phone) == "Next: tomorrow 07:00, home time") + #expect(EVText.nextGoal(away, nowMs: now, zone: Self.stockholm, phone: Self.stockholm) == "Next: tomorrow 07:00") + } + } + + // MARK: Will the car be ready? + + @Test func readinessComesFromThePlansShortfall() throws { + try AppWords.$language.withValue(.en) { + let z = Self.stockholm + let now = Self.noon(24).timeIntervalSince1970 * 1000 + let json = #"{"id":"a","plugged_in":true,"current_soc":0.35,"soc_source":"inferred","vehicle_capacity_wh":64000,"target_time":"2026-10-25T06:00:00Z","schedule":{"soc":0.8,"time_of_day_min_utc":360,"recurring":true,"days":0}}"# + let car = try lp(json) + let level = EVText.level(car, answeredAtMs: now, nowMs: now) + #expect(EVText.readiness(car, level: level, shortfallWh: 0, nowMs: now, zone: z) == .onTrack(targetPct: 80, byMs: Self.utcDate("2026-10-25T06:00:00Z").timeIntervalSince1970 * 1000)) + let short = try #require(EVText.readiness(car, level: level, shortfallWh: 18_200, nowMs: now, zone: z)) + #expect(short == .short(reachPct: 51, targetPct: 80, byMs: Self.utcDate("2026-10-25T06:00:00Z").timeIntervalSince1970 * 1000)) + #expect(EVText.readiness(short, level: level, nowMs: now, zone: z) == "Will not make it: about 51\u{00A0}% by tomorrow 07:00, not 80\u{00A0}%.") + #expect(EVText.readiness(.onTrack(targetPct: 80, byMs: now + 3_600_000), level: level, nowMs: now, zone: z) == "On track: 80\u{00A0}% by today 13:00.") + + // From a guess, it says so. + let guessed = EVText.Level(pct: 20, origin: .assumed, atMs: nil, setPct: nil) + #expect(EVText.readiness(.onTrack(targetPct: 80, byMs: now + 3_600_000), level: guessed, nowMs: now, zone: z).hasSuffix("Counted from FTW's guess of 20\u{00A0}%.")) + + // Nothing claimed without the box's word. + #expect(EVText.readiness(car, level: level, shortfallWh: nil, nowMs: now, zone: z) == nil) + let pending = try lp(json.replacingOccurrences(of: #""id":"a","#, with: #""id":"a","plan_pending":true,"#)) + #expect(EVText.readiness(pending, level: level, shortfallWh: 0, nowMs: now, zone: z) == nil) + let holding = try lp(json.replacingOccurrences(of: #""id":"a","#, with: #""id":"a","manual_active":true,"#)) + #expect(EVText.readiness(holding, level: level, shortfallWh: 0, nowMs: now, zone: z) == nil) + let done = try lp(json.replacingOccurrences(of: #""id":"a","#, with: #""id":"a","goal_complete":true,"#)) + #expect(EVText.readiness(done, level: level, shortfallWh: nil, nowMs: now, zone: z) == .reached) + } + } + + // MARK: Is FTW in control? + + static func source(_ kind: String, _ name: String, _ state: String) -> Source { + Source(.map([("kind", .text(kind)), ("name", .text(name)), ("lastOkMs", .int(0)), ("staleAfterMs", .int(10_000)), ("state", .text(state))]))! + } + + @Test func inControlComesFromTheStreamAndIsNeverGreenFromACache() { + AppWords.$language.withValue(.en) { + let live = ["meter": Self.source("meter", "sdm630", "live"), "inverter": Self.source("inverter", "sungrow", "live")] + let ok = InControl.answer(streaming: true, lastKnownMs: nil, dispatchBlockedBy: [], sources: live) + #expect(ok.tone == .ok) + #expect(ok.title == "Yes, FTW is in control") + + // The same facts from a cache, or from a stream gone quiet, are not a yes. + let cached = InControl.answer(streaming: false, lastKnownMs: Self.t, dispatchBlockedBy: [], sources: live, zone: Self.utc) + #expect(cached.tone == .stale) + #expect(cached.title == "Not live right now") + #expect(cached.detail == "Last known 10:00.") + + #expect(InControl.answer(streaming: true, lastKnownMs: nil, dispatchBlockedBy: ["meter"], sources: live).tone == .warning) + + var quiet = live + quiet["inverter"] = Self.source("inverter", "sungrow", "down") + let one = InControl.answer(streaming: true, lastKnownMs: nil, dispatchBlockedBy: [], sources: quiet) + #expect(one.tone == .warning) + #expect(one.title == "The inverter is not reporting") + #expect(one.detail == "sungrow") + quiet["meter"] = Self.source("meter", "sdm630", "stale") + #expect(InControl.answer(streaming: true, lastKnownMs: nil, dispatchBlockedBy: [], sources: quiet).title == "2 devices are not reporting") + } + AppWords.$language.withValue(.sv) { + #expect(InControl.answer(streaming: true, lastKnownMs: nil, dispatchBlockedBy: [], sources: [:]).title == "Ja, FTW styr") + #expect(AppWords.text("Is FTW in control?") == "Styr FTW?") + } + } +} + +/// The car view against the simulated box: plug in, give a level, set a +/// goal, Charge now and back to the plan, and every receipt on the way. +@MainActor +@Suite struct CarFlowTests { + @MainActor + final class Rig { + let scheduler: ManualScheduler + var box: SimulatedBox? + var app: AppModel! + + init(nowMs: Double = CarTextTests.t) { + scheduler = ManualScheduler(nowMs: nowMs) + app = AppModel(store: MemoryStore(), files: nil, passkeys: nil, scheduler: scheduler, build: "test", ua: "test", demo: { [unowned self] s in + let box = SimulatedBox(scheduler: s, house: SimulatedHouse(timeZone: CarTextTests.stockholm)) + box.resultDelayMs = 900 + box.house.chargerRampMs = 4_000 + self.box = box + return LoopbackCarrier(box: box, scheduler: s) + }) + } + + func run(_ ms: Double, step: Double = 50) async { + var left = ms + while left > 0 { + for _ in 0..<10 { await Task.yield() } + scheduler.advance(min(step, left)) + left -= step + } + for _ in 0..<10 { await Task.yield() } + } + } + + @Test func plugInGiveALevelSetAGoalAndChargeNow() async throws { + try await AppWords.$language.withValue(.en) { + let rig = Rig() + rig.app.startDemo() + let home = try #require(rig.app.home) + let box = try #require(rig.box) + home.loadpoints.activate() + await rig.run(1_500) + let car = try #require(home.loadpoints.points.first) + #expect(car.pluggedIn) + #expect(home.loadpoints.zone == CarTextTests.stockholm) + #expect(home.loadpoints.level(car)?.origin == .assumed) + #expect(home.loadpoints.level(car)?.pct == 20) + + // The level: sent, received, done, and then "you set". + let level = Task { await home.loadpoints.setLevel(car, pct: 40) } + await rig.run(100) + #expect(home.loadpoints.levelStep[car.id] == EVText.Step(ask: .level(40))) + // The tap shows as the chosen chip, never as the level: only the + // box's answer moves the number. + #expect(home.loadpoints.chosenLevel(car) == 40) + #expect(home.loadpoints.level(car)?.pct == 20) + #expect(home.loadpoints.level(car)?.origin == .assumed) + await rig.run(300) + #expect(home.loadpoints.levelStep[car.id]?.acked == true) + #expect(home.loadpoints.levelStep[car.id]?.result == nil) + await rig.run(1_500) + await level.value + #expect(home.loadpoints.levelStep[car.id]?.result == .applied(observed: 0.4)) + #expect(box.receivedCommands.last == Contract.opLoadpointSocSet) + let after = try #require(home.loadpoints.points.first) + #expect(home.loadpoints.level(after)?.origin == .you) + #expect(home.loadpoints.level(after)?.pct == 40) + + // Three quick taps send the last one, not three. + let sent = box.receivedCommands.count + let a = Task { await home.loadpoints.setLevel(after, pct: 45) } + let b = Task { await home.loadpoints.setLevel(after, pct: 50) } + let c = Task { await home.loadpoints.setLevel(after, pct: 55) } + await rig.run(5_000) + await a.value; await b.value; await c.value + #expect(box.receivedCommands.count - sent == 2) + #expect(box.house.carSocFraction == 0.55) + + // The goal, saved as the household clock, read back on it. + await home.loadpoints.saveGoalAndWait(car, rig, socPct: 90, clock: 6 * 60 + 30, recurring: true, days: 0b0011111) + #expect(box.house.schedule?.clockMin == 390) + #expect(home.loadpoints.goalSave[car.id] == .saved) + let goal = try #require(home.loadpoints.points.first?.schedule) + #expect(EVText.goal(goal, at: Date(timeIntervalSince1970: CarTextTests.t / 1000), zone: home.loadpoints.zone) == "90\u{00A0}% by 06:30 on weekdays") + + // Whether it makes it comes from the plan's shortfall. + home.loadpoints.watchPlan() + await rig.run(6_000, step: 100) + #expect(home.loadpoints.shortfallWh[car.id] != nil) + let withGoal = try #require(home.loadpoints.points.first) + #expect(home.loadpoints.readiness(withGoal) != nil) + home.loadpoints.unwatchPlan() + + // Charge now: no time limit, the charger's full current. + let charge = Task { await home.loadpoints.chargeNow(withGoal) } + await rig.run(400) + #expect(home.loadpoints.holdStep[car.id]?.acked == true) + #expect(home.loadpoints.holdSending(withGoal)) + await rig.run(1_500) + await charge.value + #expect(home.loadpoints.holdStep[car.id]?.result == .applied(observed: 11_000)) + #expect(box.house.manualHoldW == 11_000) + await rig.run(5_000, step: 100) + home.loadpoints.reread() + await rig.run(500) + let charging = try #require(home.loadpoints.points.first) + #expect(charging.manualActive) + #expect(charging.powerW == 11_000) + #expect(home.loadpoints.outcome(hold: charging)?.text == "Charging at 11\u{00A0}kW. The plan waits until you go back to it.") + // A plan set aside says nothing about the goal. + #expect(home.loadpoints.readiness(charging) == nil) + + let back = Task { await home.loadpoints.backToPlan(charging) } + await rig.run(2_000) + await back.value + #expect(home.loadpoints.outcome(hold: charging)?.text == "Back to the plan. FTW decides when to charge.") + #expect(home.loadpoints.points.first?.manualActive == false) + // A success fades once its effect is on screen. + await rig.run(LoadpointsModel.doneShownMs + 1_000, step: 500) + #expect(home.loadpoints.holdStep[car.id] == nil) + } + } + + @Test func unpluggedTakesNoLevelAndSaysWhy() async throws { + try await AppWords.$language.withValue(.en) { + let rig = Rig() + rig.app.startDemo() + let home = try #require(rig.app.home) + let box = try #require(rig.box) + home.loadpoints.activate() + await rig.run(1_500) + let car = try #require(home.loadpoints.points.first) + let set = Task { await home.loadpoints.setLevel(car, pct: 60) } + await rig.run(2_000) + await set.value + #expect(home.loadpoints.levelSet[car.id] != nil) + + box.setCarPluggedIn(false) + home.loadpoints.reread() + await rig.run(1_000) + let out = try #require(home.loadpoints.points.first) + #expect(!out.pluggedIn) + #expect(home.loadpoints.level(out) == nil) + #expect(home.loadpoints.levelSet[car.id] == nil) + + // The view offers no level now; if one is sent anyway, the box's + // refusal reads as what to do. + let refused = Task { await home.loadpoints.setLevel(out, pct: 60) } + await rig.run(2_000) + await refused.value + #expect(home.loadpoints.outcome(level: out)?.text == "Plug the car in first. FTW takes a level only while the car is plugged in.") + #expect(home.loadpoints.outcome(level: out)?.tone == .failed) + + // Plugging in again starts over: FTW assumes 20 %. + box.setCarPluggedIn(true) + home.loadpoints.reread() + await rig.run(1_000) + let back = try #require(home.loadpoints.points.first) + #expect(home.loadpoints.level(back)?.origin == .assumed) + #expect(home.loadpoints.level(back)?.pct == 20) + } + } + + /// The demo answers "Is FTW in control?" from the stream alone. + @Test func theDemoSaysFTWIsInControl() async throws { + let rig = Rig() + rig.app.startDemo() + let home = try #require(rig.app.home) + await rig.run(3_000) + let site = home.site + let answer = InControl.answer(streaming: site.isLive, lastKnownMs: site.lastFrameAtMs, dispatchBlockedBy: site.session.dispatchBlockedBy, sources: site.session.sources) + #expect(answer.tone == .ok) + // No request was needed for it. + #expect(try #require(rig.box).receivedRequests.isEmpty) + } + + /// No timer reads the chargers. A screen appearing does, a command + /// does, and the stream saying the car started drawing does. + @Test func theChargersAreReadOnEventsNotOnATimer() async throws { + let rig = Rig() + rig.app.startDemo() + let home = try #require(rig.app.home) + let box = try #require(rig.box) + func reads() -> Int { box.receivedRequests.filter { $0 == "GET /api/loadpoints" }.count } + home.loadpoints.activate() + await rig.run(1_000) + #expect(reads() == 1) + await rig.run(60_000, step: 500) + #expect(reads() == 1) + + // A second screen appearing is a reason to look again. + home.loadpoints.activate() + await rig.run(1_000) + #expect(reads() == 2) + home.loadpoints.deactivate() + + // Charge now: one read after the box's answer, one when the stream + // shows the car drawing power. + let car = try #require(home.loadpoints.points.first) + let charge = Task { await home.loadpoints.chargeNow(car) } + await rig.run(2_000) + await charge.value + #expect(reads() == 3) + #expect(home.loadpoints.points.first?.powerW == 0) + await rig.run(5_000, step: 250) + #expect(reads() == 4) + #expect(home.loadpoints.points.first?.powerW == 11_000) + await rig.run(60_000, step: 500) + #expect(reads() == 4) + + // The plan's shortfall: read when the car view opens and when the + // car's plan changes, not once a minute. + let back = Task { await home.loadpoints.backToPlan(car) } + await rig.run(3_000) + await back.value + func plans() -> Int { box.receivedRequests.filter { $0 == "GET /api/mpc/plan" }.count } + home.loadpoints.watchPlan() + await rig.run(1_000) + #expect(plans() == 1) + await rig.run(180_000, step: 1_000) + #expect(plans() == 1) + } + + /// A cold start paints the car the app last saw, from the cache it + /// writes for its widgets, dated and marked as not live. + @Test func aColdStartPaintsTheCarFromTheCache() throws { + let dir = URL(fileURLWithPath: NSTemporaryDirectory()).appendingPathComponent("ftw-car-\(UUID().uuidString)") + let shared = AppGroupFiles(directory: dir) + let readAt = CarTextTests.t - 600_000 + let car = CarEntry.Car(loadpointID: "garage", pluggedIn: true, socPct: 64, socSource: "vehicle", socReadAtMs: readAt - 120_000, powerW: 0, targetSocPct: 80) + try shared.write(CacheRecord(siteId: "site-1", carrier: .relay, srcState: .live, readAtMs: readAt, value: CarEntry(cars: [car]))) + let scheduler = ManualScheduler(nowMs: CarTextTests.t) + let site = SiteModel(siteId: "site-1", build: "test", ua: "test", scheduler: scheduler, files: nil) + let model = LoadpointsModel(site: site, charging: nil, cache: CacheReader(sources: [shared])) + let lp = try #require(model.points.first) + #expect(model.fromCache) + #expect(model.stale) + #expect(model.readAtMs == readAt) + #expect(lp.pluggedIn) + #expect(lp.targetSocPct == 80) + let level = try #require(model.level(lp)) + #expect(level.pct == 64) + // The car's report was two minutes old when cached, ten minutes ago. + #expect(level.origin == .carEarlier) + #expect(level.atMs == readAt - 120_000) + // Another home's cache paints nothing. + let other = SiteModel(siteId: "site-2", build: "test", ua: "test", scheduler: scheduler, files: nil) + #expect(LoadpointsModel(site: other, charging: nil, cache: CacheReader(sources: [shared])).points.isEmpty) + } + + /// A save sends the household clock and today's UTC minute, built from + /// nothing read. A Core with srcfl/ftw#1586 takes the clock; an older + /// one reads the minute on today's date. Either way 07:00 is kept. + @Test func aGoalSavesBothClocksAndEitherCoreKeepsTheTime() async throws { + for (day, utc) in [(24, 300), (26, 360)] { + for household in [true, false] { + let rig = Rig(nowMs: CarTextTests.noon(day).timeIntervalSince1970 * 1000) + rig.app.startDemo() + let home = try #require(rig.app.home) + let box = try #require(rig.box) + box.readsHouseholdClock = household + home.loadpoints.activate() + await rig.run(1_500) + let car = try #require(home.loadpoints.points.first) + await home.loadpoints.saveGoalAndWait(car, rig, socPct: 80, clock: 7 * 60, recurring: true, days: 0b0011111) + let body = try #require(box.lastScheduleBody) + #expect(body["time_of_day_min"]?.number == 420) + #expect(body["time_of_day_min_utc"]?.number == Double(utc)) + #expect(box.house.schedule?.clockMin == 420) + // Read back: the clock when the box sends it, the minute otherwise. + let back = try #require(home.loadpoints.points.first?.schedule) + #expect(back.clockMin == (household ? 420 : nil)) + #expect(back.timeOfDayMinUTC == utc) + #expect(EVText.goalClock(back, at: CarTextTests.noon(day), zone: home.loadpoints.zone) == 420) + } + } + } + + /// Every demo situation opens, so each screen can be shown without a box. + @Test func everyDemoCarOpens() async throws { + for situation in DemoCar.allCases { + let rig = Rig() + rig.app.startDemo() + let home = try #require(rig.app.home) + situation.apply(to: try #require(rig.box).house, nowMs: rig.scheduler.nowMs) + home.loadpoints.activate() + await rig.run(1_500) + let car = try #require(home.loadpoints.points.first) + switch situation { + case .unplugged: #expect(!car.pluggedIn) + case .car: #expect(home.loadpoints.level(car)?.origin == .car) + case .carOld: #expect(home.loadpoints.level(car)?.origin == .carEarlier) + case .assumed: #expect(home.loadpoints.level(car)?.origin == .assumed) + case .estimated, .late: #expect(home.loadpoints.level(car)?.origin == .estimated) + case .noGoal: #expect(car.schedule == nil) + case .charging: #expect(car.manualActive) + } + } + } +} + +extension LoadpointsModel { + @MainActor + func saveGoalAndWait(_ lp: Loadpoint, _ rig: CarFlowTests.Rig, socPct: Double, clock: Int, recurring: Bool, days: Int) async { + let task = Task { await saveGoal(lp, socPct: socPct, clock: clock, recurring: recurring, days: days, zone: zone) } + await rig.run(1_500) + await task.value + } +} + +/// Timers that fire in the reverse of the order they were set, as the live +/// scheduler's sleeping tasks may. +@MainActor +final class ReversingScheduler: Scheduler { + var nowMs: Double = 0 + private var due: [@MainActor () -> Void] = [] + + func after(_ ms: Double, _ action: @escaping @MainActor () -> Void) -> Cancellable { + due.append(action) + return Cancellable() + } + + func random() -> Double { 0.5 } + + func fireAll() { + let now = due + due = [] + for action in now.reversed() { action() } + } +} + +@MainActor +@Suite struct LoopbackTests { + /// The demo's answers arrive in the order the box sent them, whatever + /// order the timers fire in. Out of order, a chunk before its head read + /// as "your box didn't answer". + @Test func framesArriveInTheOrderTheyWereSent() { + let scheduler = ReversingScheduler() + let box = SimulatedBox(scheduler: ManualScheduler()) + let carrier = LoopbackCarrier(box: box, scheduler: scheduler) + var got: [Bytes] = [] + carrier.setHandlers(onFrame: { got.append($0) }, onStatus: { _ in }) + scheduler.fireAll() + let sent: [Bytes] = [[1], [2], [3]] + for f in sent { box.send?(f) } + scheduler.fireAll() + #expect(got == sent) + } +} diff --git a/appleApp/FTWKit/Tests/FTWKitTests/ContractTests.swift b/appleApp/FTWKit/Tests/FTWKitTests/ContractTests.swift new file mode 100644 index 0000000..f9b3f6e --- /dev/null +++ b/appleApp/FTWKit/Tests/FTWKitTests/ContractTests.swift @@ -0,0 +1,89 @@ +import Foundation +import Testing +@testable import FTWKit +import FTWDemo + +/// The registry is one file shared with the box and the web app. Every name +/// this app spells has to be the registry's. `Contract` is written by hand, +/// the way the web app writes its own, and this is what keeps it honest. +@Suite struct ContractTests { + let yaml: String + + init() throws { + yaml = try RepoFiles.read("protocol/registry.yaml") + } + + /// Lines of one top-level section, without comments. + func section(_ name: String) -> [Substring] { + var out = [Substring]() + var inside = false + for line in yaml.split(separator: "\n", omittingEmptySubsequences: false) { + if line.hasPrefix("\(name):") { inside = true; continue } + if inside, let first = line.first, !first.isWhitespace, first != "#" { break } + if inside, !line.trimmingCharacters(in: .whitespaces).hasPrefix("#") { out.append(line) } + } + return out + } + + /// The value after `key: ` on every line of a section that has one. + func values(_ section: String, _ key: String) -> [String] { + self.section(section).compactMap { line in + guard let range = line.range(of: "\(key): ") else { return nil } + return String(line[range.upperBound...].prefix { $0 != "," && $0 != "}" && $0 != " " }) + } + } + + func list(_ section: String) -> [String] { + self.section(section).compactMap { line in + let t = line.trimmingCharacters(in: .whitespaces) + return t.hasPrefix("- ") ? String(t.dropFirst(2)) : nil + } + } + + @Test func namesMatchTheSharedRegistry() { + #expect(values("scopes", "name") == Contract.scopes) + #expect(values("ops", "name") == Contract.ops) + #expect(list("capabilities") == Contract.capabilities) + #expect(values("modes", "key") == Contract.modes.map(\.key)) + #expect(values("modes", "tier") == Contract.modes.map(\.tier)) + let errors = values("errors", "code") + values("client_errors", "code") + #expect(Set(errors) == Set(Contract.retryable.keys)) + for code in errors { + let line = yaml.split(separator: "\n").first { $0.contains("code: \(code),") }! + #expect(line.contains("retryable: \(Contract.isRetryable(code))"), "\(code)") + } + #expect(yaml.contains("source_states: [\(Contract.sourceStates.joined(separator: ", "))]")) + #expect(yaml.contains("carrier_states: [\(Contract.carrierStates.joined(separator: ", "))]")) + } + + /// FTWShared spells the same states as enums, for widgets that never + /// link FTWKit. + @Test func sharedStatesAreTheRegistrys() { + #expect([CarrierKind.webrtc, .relay, .cache, .none].map(\.rawValue) == Contract.carrierStates) + #expect([SourceState.live, .lagging, .stale, .down, .never].map(\.rawValue) == Contract.sourceStates) + #expect(Contract.sourceStates.allSatisfy { SourceState(rawValue: $0) != nil }) + #expect(Contract.carrierStates.allSatisfy { CarrierKind(rawValue: $0) != nil }) + } + + @Test func frozenFieldIDsAreTheRegistrys() { + typealias F = Contract.FID + let ids: [(Int, String)] = [ + (F.mode, "mode"), (F.gridW, "grid_w"), (F.pvW, "pv_w"), (F.batteryW, "battery_w"), + (F.batterySoc, "battery_soc"), (F.loadW, "load_w"), (F.srcGrid, "src_grid"), + (F.srcPV, "src_pv"), (F.srcBattery, "src_battery"), (F.evW, "ev_w"), + ] + let frozen = section("frozen_fields").filter { $0.contains("{ name:") } + #expect(frozen.count == ids.count) + for (id, name) in ids { + #expect(frozen.contains { $0.trimmingCharacters(in: .whitespaces).hasPrefix("\(id): { name: \(name),") }, "\(id) \(name)") + } + } + + /// The demo box is not the box, but it may not invent the box's names. + @Test func theDemoBoxSpeaksOnlyRegistryNames() { + let modes = Set(Contract.modes.map(\.key)) + #expect(SimulatedBox.modes.allSatisfy { modes.contains($0.key) }) + #expect(SimulatedBox.modes.allSatisfy { m in Contract.modes.first { $0.key == m.key }?.tier == m.tier }) + #expect(Set(SimulatedBox.caps).isSubset(of: Contract.capabilities)) + } +} diff --git a/appleApp/FTWKit/Tests/FTWKitTests/Fakes.swift b/appleApp/FTWKit/Tests/FTWKitTests/Fakes.swift new file mode 100644 index 0000000..5040eb3 --- /dev/null +++ b/appleApp/FTWKit/Tests/FTWKitTests/Fakes.swift @@ -0,0 +1,36 @@ +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) + } +} diff --git a/appleApp/FTWKit/Tests/FTWKitTests/FormatTests.swift b/appleApp/FTWKit/Tests/FTWKitTests/FormatTests.swift new file mode 100644 index 0000000..be1185b --- /dev/null +++ b/appleApp/FTWKit/Tests/FTWKitTests/FormatTests.swift @@ -0,0 +1,101 @@ +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 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 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 { + try AppWords.$language.withValue(.en) { + let unplugged = Loadpoint(try JSON(parsing: Array(#"{"id":"a","plugged_in":false,"current_soc":0.5}"#.utf8))) + #expect(EVText.status(unplugged) == "Not plugged in") + #expect(unplugged.socPct == nil) + + let charging = Loadpoint(try JSON(parsing: Array(#"{"id":"a","plugged_in":true,"current_power_w":7200,"current_soc":0.41,"delivered_wh_session":3040}"#.utf8))) + #expect(EVText.status(charging) == "Charging at 7.2\u{00A0}kW") + #expect(charging.socPct == 41) + #expect(EVText.session(charging) == "3.0\u{00A0}kWh this session") + + let paused = Loadpoint(try JSON(parsing: Array(#"{"id":"a","plugged_in":true,"manual_active":true,"manual_charge_w":0,"manual":{"state":"paused"}}"#.utf8))) + #expect(EVText.isPaused(paused)) + #expect(EVText.status(paused) == "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.") + + #expect(EVText.days(0) == "every day") + #expect(EVText.days(0b0011111) == "weekdays") + #expect(EVText.days(0b1100000) == "weekends") + #expect(EVText.days(0b0000101) == "Mon, Wed") + } + } + + /// A level from the car is as old as the car's report. Any other level + /// is the box's account as of its answer. + @Test func theCarsLevelCarriesWhenItWasRead() throws { + let fromCar = Loadpoint(try JSON(parsing: Array(#"{"id":"a","plugged_in":true,"current_soc":0.6,"soc_source":"vehicle","vehicle_soc_age_s":120}"#.utf8))) + #expect(fromCar.socReadAtMs(answeredAtMs: 1_000_000) == 880_000) + let assumed = Loadpoint(try JSON(parsing: Array(#"{"id":"a","plugged_in":true,"current_soc":0.2,"soc_source":"assumed"}"#.utf8))) + #expect(assumed.socReadAtMs(answeredAtMs: 1_000_000) == 1_000_000) + let unplugged = Loadpoint(try JSON(parsing: Array(#"{"id":"a","plugged_in":false,"current_soc":0.6}"#.utf8))) + #expect(unplugged.socReadAtMs(answeredAtMs: 1_000_000) == nil) + } + + @Test func chargeCurrentFallsBackLikeTheBoxPage() throws { + let bare = Loadpoint(try JSON(parsing: Array(#"{"id":"a","plugged_in":true}"#.utf8))) + #expect(EVText.current(bare) == EVText.Current(minA: 6, maxA: 16, wattsPerAmp: 690, phases: 3)) + 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 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) + } + +} 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..e4ae48f --- /dev/null +++ b/appleApp/FTWKit/Tests/FTWKitTests/IdentityTests.swift @@ -0,0 +1,214 @@ +import CoreImage +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] } + /// The file also holds the escrow's vectors; this app has no escrow + /// yet and reads only what it uses. + struct PRFVector: Decodable { + let output: String + let credentialId: String + } + 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 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 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 PairingImageTests { + /// A QR as a PNG, the way a screenshot of the box's Settings page holds it. + func png(_ text: String) throws -> Data { + let filter = try #require(CIFilter(name: "CIQRCodeGenerator")) + filter.setValue(Data(text.utf8), forKey: "inputMessage") + filter.setValue("M", forKey: "inputCorrectionLevel") + let code = try #require(filter.outputImage).transformed(by: CGAffineTransform(scaleX: 8, y: 8)) + // A quiet zone, as on a screen. + let framed = code.composited(over: CIImage(color: .white).cropped(to: code.extent.insetBy(dx: -40, dy: -40))) + return try #require(CIContext().pngRepresentation(of: framed, format: .RGBA8, colorSpace: CGColorSpace(name: CGColorSpace.sRGB)!)) + } + + @Test func aPictureOfThePairingQRGivesTheLink() throws { + let url = Enrollment(boxStaticPublic: Bytes(repeating: 7, count: 32), pairingCode: Bytes(repeating: 1, count: 16), lanHint: "", rendezvousSecret: Bytes(repeating: 9, count: 32)).url() + #expect(PairingImage.link(in: try png(url)) == url) + } + + @Test func anyOtherPictureGivesNothing() throws { + #expect(PairingImage.link(in: try png("https://example.com/p#v2.nope")) == nil) + #expect(PairingImage.link(in: Data("not a picture".utf8)) == nil) + } +} + +@Suite struct EnrollmentTests { + let good = Enrollment(boxStaticPublic: Bytes(repeating: 1, count: 32), pairingCode: Bytes(repeating: 2, count: 16), lanHint: "10.0.0.2:8080", rendezvousSecret: Bytes(repeating: 3, count: 32)) + + @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]) + } +} + +@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) + } +} diff --git a/appleApp/FTWKit/Tests/FTWKitTests/LiveBoxTests.swift b/appleApp/FTWKit/Tests/FTWKitTests/LiveBoxTests.swift new file mode 100644 index 0000000..91a4274 --- /dev/null +++ b/appleApp/FTWKit/Tests/FTWKitTests/LiveBoxTests.swift @@ -0,0 +1,82 @@ +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 +/// +/// 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() + let 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 now = Date().timeIntervalSince1970 * 1000 + let prices = try await session.prices(fromMs: now, toMs: now + 86_400_000) + print("live: prices \(prices.zone) \(prices.currency), \(prices.slots.count) slots, stale \(prices.stale)") + + // Reaching an actuating route through the passthrough is refused and + // points at the command instead. + 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..40680ea --- /dev/null +++ b/appleApp/FTWKit/Tests/FTWKitTests/Loopback.swift @@ -0,0 +1,180 @@ +import Foundation +@testable import FTWKit +import FTWDemo + +/// The box's end of Noise, around a `SimulatedBox`: what the Go box does in +/// front of its protocol handler. +@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/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..642dbe4 --- /dev/null +++ b/appleApp/FTWKit/Tests/FTWKitTests/SessionTests.swift @@ -0,0 +1,295 @@ +import Foundation +import Testing +@testable import FTWKit +import FTWDemo + +@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: .delete, path: "/api/loadpoints/garage/schedule"), "E_NEEDS_STEP_UP"), + (APIRequest(method: .post, path: "/api/mode"), "E_USE_CMD"), + (APIRequest(method: .get, path: "/api/config"), "E_LOCAL_ONLY"), + (APIRequest(method: .get, path: "/api/nothing/here"), "E_UNKNOWN_OP"), + ] { + 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: .delete, path: "/api/loadpoints/garage/schedule", 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 aModeCommandIsAckedAndApplied() async throws { + let rig = Rig() + await rig.run(200) + let task = Task { try await rig.session.command(Contract.opSetMode, args: [("mode", .text("self_consumption"))]) } + await rig.run(200) + let result = try await task.value + #expect(result.state == .applied) + #expect(result.observedValue == 3) + 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.prices(fromMs: rig.scheduler.nowMs, toMs: rig.scheduler.nowMs + 3_600_000) } + await rig.run(50) + rig.relay.closeCurrent(code: 1006, reason: "") + await rig.run(20) + 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 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 pricesAreAsked() async throws { + let rig = Rig() + await rig.run(200) + 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 prices.value.currency == "SEK") + } + + /// The relay serves four streams a home. A fifth phone is turned away + /// with 4429 before it reaches the box, and that is not a fault. + @Test func aFullRelayIsBusyNotBroken() async throws { + let rig = Rig() + await rig.run(200) + rig.relay.closeCurrent(code: RelayCarrier.closeBusy, reason: "streams") + await rig.run(20) + #expect(rig.session.state.busy) + #expect(rig.session.state.phase == .failed) + // The readings stay, and the next stream clears it. + #expect(!rig.session.state.fields.isEmpty) + rig.endpoint.dropSession() + await rig.run(1_000, step: 20) + #expect(rig.session.state.phase == .streaming) + #expect(!rig.session.state.busy) + } + + @Test func revocationTerminatesAndSaysWhy() async throws { + let rig = Rig() + await rig.run(200) + 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) + } +} 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/FTWKit/Tests/FTWKitTests/WidgetPricesTests.swift b/appleApp/FTWKit/Tests/FTWKitTests/WidgetPricesTests.swift new file mode 100644 index 0000000..b0c4667 --- /dev/null +++ b/appleApp/FTWKit/Tests/FTWKitTests/WidgetPricesTests.swift @@ -0,0 +1,80 @@ +import Foundation +import FTWDemo +import FTWShared +import Testing +@testable import FTWKit + +/// The background refresh that leaves tomorrow's prices for the widgets. +@MainActor +@Suite struct WidgetPricesTests { + let scheduler = ManualScheduler() + let shared = AppGroupFiles(directory: URL(fileURLWithPath: NSTemporaryDirectory()).appendingPathComponent("ftw-shared-\(UUID().uuidString)")) + + func site(wakes: Counter) -> SiteModel { + let site = SiteModel(siteId: "home", build: "test", ua: "test", scheduler: scheduler, files: nil, shared: shared) + site.sharedWritten = { wakes.count += 1 } + site.start() + return site + } + + func run(_ ms: Double, step: Double = 20) async { + var left = ms + while left > 0 { + for _ in 0..<10 { await Task.yield() } + scheduler.advance(min(step, left)) + left -= step + } + for _ in 0..<10 { await Task.yield() } + } + + @Test func aBackgroundRefreshLeavesPricesAndWakesTheWidgets() async throws { + let wakes = Counter() + let site = site(wakes: wakes) + site.connect(SimulatedBox.loopback(scheduler)) + let asked = scheduler.nowMs + let task = Task { await site.refreshSharedPrices() } + await run(2_000) + let written = try #require(await task.value) + #expect(!written.slots.isEmpty) + + let read = try #require(CacheReader(sources: [shared]).read(PriceEntry.self, siteId: "home")) + #expect(read.value.prices == written) + #expect(read.carrier == .cache) + #expect(read.record.readAtMs >= asked && read.record.readAtMs <= scheduler.nowMs) + #expect(wakes.count == 1) + #expect(await site.sharedPricesEndMs() == written.slots.map { $0.startMs + $0.durationMs }.max()) + + // The same prices again are neither written nor reloaded. + let again = Task { await site.refreshSharedPrices() } + await run(2_000) + #expect(await again.value == written) + #expect(wakes.count == 1) + } + + /// A sign-out that lands while a write is on its way wins: the home is + /// not put back for the widgets. + @Test func aSignOutBeatsAWriteOnItsWay() async throws { + let wakes = Counter() + let site = site(wakes: wakes) + let prices = Prices(zone: "SE3", currency: "SEK", slots: [PriceSlot(startMs: 0, durationMs: 3_600_000, spotMinor: 40, totalMinor: 60)], stale: false) + let write = try #require(site.sharePrices(prices, fromMs: 0)) + site.destroy() + shared.removeAll() + await write.value + #expect(shared.siteIDs(PriceEntry.self).isEmpty) + #expect(wakes.count == 0) + } + + /// No stream, no write: the widgets keep what they had, and the task + /// ends on time rather than holding the wake. + @Test func withoutAStreamItGivesUpAndWritesNothing() async { + let wakes = Counter() + let site = site(wakes: wakes) + let task = Task { await site.refreshSharedPrices(waitMs: 5_000) } + await run(5_500, step: 250) + #expect(await task.value == nil) + #expect(shared.siteIDs(PriceEntry.self).isEmpty) + #expect(wakes.count == 0) + #expect(await site.sharedPricesEndMs() == nil) + } +} diff --git a/appleApp/FTWKit/Tests/FTWSharedTests/CacheTests.swift b/appleApp/FTWKit/Tests/FTWSharedTests/CacheTests.swift new file mode 100644 index 0000000..4166398 --- /dev/null +++ b/appleApp/FTWKit/Tests/FTWSharedTests/CacheTests.swift @@ -0,0 +1,92 @@ +import Foundation +import Testing +@testable import FTWShared + +@Suite struct CacheTests { + let files = AppGroupFiles(directory: URL(fileURLWithPath: NSTemporaryDirectory()).appendingPathComponent("ftw-cache-\(UUID().uuidString)")) + + func prices(_ total: Double = 42) -> PriceEntry { + PriceEntry(fromMs: 0, prices: Prices(zone: "SE3", currency: "SEK", slots: [PriceSlot(startMs: 0, durationMs: 3_600_000, spotMinor: total, totalMinor: total)], stale: false)) + } + + @Test func aRecordReadsBackAsTheCacheNeverAsLive() throws { + try files.write(CacheRecord(siteId: "home", carrier: .relay, srcState: .live, readAtMs: 1_000, value: prices())) + let read = try #require(CacheReader(sources: [files]).read(PriceEntry.self, siteId: "home")) + #expect(read.value == prices()) + #expect(read.record.carrier == .relay) + #expect(read.carrier == .cache) + #expect(read.srcState == .live) + #expect(read.origin == .app) + #expect(read.ageMs(nowMs: 61_000) == 60_000) + } + + @Test func theFilesAreTheNamesTheAppAndWidgetsAgreeOn() throws { + try files.write(CacheRecord(siteId: "home", carrier: .relay, srcState: .live, readAtMs: 1, value: prices())) + try files.write(CacheRecord(siteId: "home", carrier: .relay, srcState: .live, readAtMs: 1, value: CarEntry(cars: []))) + let names = try FileManager.default.contentsOfDirectory(atPath: files.directory.path).sorted() + #expect(names == ["car-home", "prices-home"]) + } + + @Test func aRecordOfAnotherShapeOrHomeIsNotRead() throws { + try files.write(CacheRecord(siteId: "home", carrier: .relay, srcState: .live, readAtMs: 1, value: prices())) + #expect(files.read(PriceEntry.self, siteId: "other") == nil) + #expect(files.read(CarEntry.self, siteId: "home") == nil) + + // A newer app wrote a version this one does not know. + var json = try JSONSerialization.jsonObject(with: Data(contentsOf: files.url(PriceEntry.name, "home"))) as! [String: Any] + json["version"] = PriceEntry.version + 1 + try JSONSerialization.data(withJSONObject: json).write(to: files.url(PriceEntry.name, "home")) + #expect(files.read(PriceEntry.self, siteId: "home") == nil) + } + + @Test func theNewestHomeIsTheOneTheAppHadOpen() throws { + try files.write(CacheRecord(siteId: "cabin", carrier: .relay, srcState: .live, readAtMs: 1, value: prices())) + let past = Date(timeIntervalSinceNow: -3_600) + try FileManager.default.setAttributes([.modificationDate: past], ofItemAtPath: files.url(PriceEntry.name, "cabin").path) + try files.write(CacheRecord(siteId: "home", carrier: .relay, srcState: .live, readAtMs: 2, value: prices())) + #expect(CacheReader(sources: [files]).latestSiteID(PriceEntry.self) == "home") + #expect(files.siteIDs(PriceEntry.self) == ["home", "cabin"]) + #expect(files.siteIDs(CarEntry.self).isEmpty) + } + + /// A second source, such as a copy from FTW Cloud, feeds the + /// same records. The newest reading wins whichever source holds it. + @Test func anotherSourceFeedsTheSameRecords() throws { + struct Mirror: CacheSource { + let record: CacheRecord + var origin: CacheOrigin { .app } + func read(_ type: Value.Type, siteId: String) -> CacheRecord? { + record.siteId == siteId ? record as? CacheRecord : nil + } + func siteIDs(_ type: Value.Type) -> [String] { Value.self == PriceEntry.self ? [record.siteId] : [] } + } + try files.write(CacheRecord(siteId: "home", carrier: .relay, srcState: .live, readAtMs: 1_000, value: prices(10))) + let newer = Mirror(record: CacheRecord(siteId: "home", carrier: .relay, srcState: .stale, readAtMs: 2_000, value: prices(20))) + let older = Mirror(record: CacheRecord(siteId: "home", carrier: .relay, srcState: .live, readAtMs: 500, value: prices(30))) + #expect(CacheReader(sources: [files, newer]).read(PriceEntry.self, siteId: "home")?.value == prices(20)) + #expect(CacheReader(sources: [files, older]).read(PriceEntry.self, siteId: "home")?.value == prices(10)) + } + + @Test func signingOutLeavesWidgetsNothing() throws { + try files.write(CacheRecord(siteId: "home", carrier: .relay, srcState: .live, readAtMs: 1, value: prices())) + files.removeAll() + #expect(files.read(PriceEntry.self, siteId: "home") == nil) + #expect(files.siteIDs(PriceEntry.self).isEmpty) + } + + @Test func theSameCarAtAnotherMomentIsTheSameReading() { + let car = CarEntry.Car(loadpointID: "garage", pluggedIn: true, socPct: 60, socSource: "inferred", socReadAtMs: 1, powerW: 7_200, targetSocPct: 80) + var later = car + later.socReadAtMs = 2 + #expect(CarEntry(cars: [car]).sameReading(CarEntry(cars: [later]))) + later.socPct = 61 + #expect(!CarEntry(cars: [car]).sameReading(CarEntry(cars: [later]))) + } + + /// No widget can open a file the app could not have written. + @Test func theAppGroupComesFromTheBundleOrNotAtAll() { + #expect(AppGroupFiles.shared(bundle: Bundle(for: BundleMarker.self)) == nil) + } +} + +private final class BundleMarker {} diff --git a/appleApp/FTWKit/Tests/FTWSharedTests/PriceGlanceTests.swift b/appleApp/FTWKit/Tests/FTWSharedTests/PriceGlanceTests.swift new file mode 100644 index 0000000..df2cb40 --- /dev/null +++ b/appleApp/FTWKit/Tests/FTWSharedTests/PriceGlanceTests.swift @@ -0,0 +1,270 @@ +import Foundation +import Testing +@testable import FTWShared + +/// The price widgets' timeline, worked out from the cache alone. Times are +/// Stockholm's; 12 October 2026 is a Monday. These read English: the tests +/// run in the development language. +@Suite struct PriceGlanceTests { + static let hourMs: Double = 3_600_000 + let calendar: Calendar = { + var c = Calendar(identifier: .gregorian) + c.timeZone = TimeZone(identifier: "Europe/Stockholm")! + return c + }() + + /// Wall clock in Stockholm, as ms. + func at(_ day: Int, _ hour: Int, _ minute: Int = 0, month: Int = 10) -> Double { + calendar.date(from: DateComponents(year: 2026, month: month, day: day, hour: hour, minute: minute))!.timeIntervalSince1970 * 1000 + } + + /// Slots back to back from `fromMs`, each `dtMs` long, priced by index. + func slots(from fromMs: Double, count: Int, dtMs: Double = hourMs, total: (Int) -> Double = { _ in 50 }) -> [PriceSlot] { + (0.. CacheRead { + let prices = Prices(zone: "SE3", currency: "SEK", slots: slots, stale: false) + let record = CacheRecord(siteId: "home", carrier: carrier, srcState: srcState, readAtMs: readAtMs, value: PriceEntry(fromMs: slots.first?.startMs ?? 0, prices: prices)) + return CacheRead(record: record, origin: .app) + } + + // MARK: The timeline + + @Test func oneMomentNowAndOneAtEveryHourTheBoxPriced() { + let fetched = at(12, 13, 5) + let r = read(slots(from: at(12, 0), count: 48), readAtMs: fetched) + let moments = PriceGlance.moments(r, nowMs: at(12, 13, 30), calendar: calendar) + #expect(moments.first?.atMs == at(12, 13, 30)) + #expect(moments.count == 1 + 35) + let steps = zip(moments.dropFirst(), moments.dropFirst(2)).map { $1.atMs - $0.atMs } + #expect(steps.allSatisfy { $0 == Self.hourMs }) + #expect(moments.last?.atMs == at(14, 0)) + } + + /// Quarter-hour prices give a moment every quarter hour, and a box that + /// mixes resolutions is followed slot by slot. + @Test func theTimelineFollowsTheResolutionTheBoxSent() { + let quarter = Self.hourMs / 4 + let today = slots(from: at(12, 0), count: 24) + let tomorrow = slots(from: at(13, 0), count: 96, dtMs: quarter) + let moments = PriceGlance.moments(read(today + tomorrow, readAtMs: at(12, 13, 5)), nowMs: at(12, 22, 30), calendar: calendar) + let times = moments.map(\.atMs) + #expect(times.prefix(3) == [at(12, 22, 30), at(12, 23), at(13, 0)]) + #expect(times.contains(at(13, 0, 15))) + let tail = zip(times.dropFirst(2), times.dropFirst(3)).map { $1 - $0 } + #expect(tail.allSatisfy { $0 == quarter }) + #expect(moments.last?.state == .ended(lastMs: at(14, 0))) + } + + @Test func eachMomentShowsTheSlotItFallsIn() { + let r = read(slots(from: at(12, 0), count: 24) { Double(10 + $0) }, readAtMs: at(12, 6)) + for m in PriceGlance.moments(r, nowMs: at(12, 9, 40), calendar: calendar) where m.state == .price { + let hour = calendar.component(.hour, from: Date(timeIntervalSince1970: m.atMs / 1000)) + #expect(m.current?.totalMinor == Double(10 + hour)) + #expect(m.priceText == PriceStrip.text(Double(10 + hour), "SEK")) + } + } + + // MARK: When the prices run out + + @Test func theLastMomentSaysWhenThePricesRanOut() throws { + let r = read(slots(from: at(12, 0), count: 18), readAtMs: at(12, 8)) + let last = try #require(PriceGlance.moments(r, nowMs: at(12, 9), calendar: calendar).last) + #expect(last.atMs == at(12, 18)) + #expect(last.state == .ended(lastMs: at(12, 18))) + #expect(last.isEmpty) + #expect(last.priceText == nil) + #expect(last.cheapestText == nil) + #expect(last.curve.isEmpty) + #expect(last.emptyText == "No prices after 18:00 · open FTW") + #expect(last.inlineText == "No prices · open FTW") + } + + /// Nothing after the end is priced: not the last hour stretched, not + /// yesterday's day started over. + @Test func noMomentAfterTheEndShowsAPrice() { + let r = read(slots(from: at(12, 0), count: 48), readAtMs: at(12, 13, 5)) + for h in 0..<72 { + let m = PriceGlance.moment(r, atMs: at(14, 0) + Double(h) * Self.hourMs + 1, calendar: calendar) + #expect(m.state == .ended(lastMs: at(14, 0))) + #expect(m.current == nil && m.priceText == nil && m.curve.isEmpty) + } + } + + /// The proof case: fetched Monday just after the market published, and + /// not opened since. Thirty-six hours on, the widget is grey and says + /// which midnight the prices ended at. + @Test func thirtySixHoursLaterTheWidgetIsGrey() throws { + let fetched = at(12, 13, 5) + let r = read(slots(from: at(12, 0), count: 48), readAtMs: fetched) + let later = fetched + 36 * Self.hourMs + let moments = PriceGlance.moments(r, nowMs: later, calendar: calendar) + #expect(moments.count == 1) + let m = try #require(moments.first) + #expect(m.state == .ended(lastMs: at(14, 0))) + #expect(m.emptyText == "No prices after Tue 24:00 · open FTW") + #expect(m.fetchedText == "Fetched Mon 13:05") + #expect(m.carrier == .cache) + } + + /// An end at midnight is 24:00 of the day it closes, also on the day the + /// clocks go back and the day has 25 hours. + @Test func midnightReadsAsTheEndOfTheDayAcrossTheClockChange() throws { + let day = at(25, 0) + let next = at(26, 0) + #expect(next - day == 25 * Self.hourMs) + let r = read(slots(from: day, count: 25), readAtMs: day) + let last = try #require(PriceGlance.moments(r, nowMs: at(25, 20), calendar: calendar).last) + #expect(last.atMs == next) + #expect(last.emptyText == "No prices after Sun 24:00 · open FTW") + #expect(PriceGlance.moment(r, atMs: at(25, 22, 30), calendar: calendar).cheapestText == "Cheapest now until 24") + } + + /// One failed fetch at midday leaves a hole. The widget says so for as + /// long as it lasts and picks the prices up again after it. + @Test func aHoleIsSaidNotFilled() { + let morning = slots(from: at(12, 0), count: 6) + let afternoon = slots(from: at(12, 12), count: 12) + let moments = PriceGlance.moments(read(morning + afternoon, readAtMs: at(12, 5)), nowMs: at(12, 5, 30), calendar: calendar) + let gap = moments.first { $0.atMs == at(12, 6) } + #expect(gap?.state == .gap(nextMs: at(12, 12))) + #expect(gap?.emptyText == "No price until 12:00 · open FTW") + #expect(gap?.priceText == nil) + #expect(!moments.contains { $0.atMs > at(12, 6) && $0.atMs < at(12, 12) }) + #expect(moments.first { $0.atMs == at(12, 12) }?.state == .price) + } + + @Test func beforeTheFirstPriceTheWidgetWaitsForIt() { + let r = read(slots(from: at(12, 6), count: 18), readAtMs: at(12, 5)) + let m = PriceGlance.moment(r, atMs: at(12, 5), calendar: calendar) + #expect(m.state == .gap(nextMs: at(12, 6))) + #expect(m.emptyText == "No price until 06:00 · open FTW") + } + + // MARK: Cheapest next + + /// A day that is dear until midnight and cheapest from 02 to 04. + func night(dtMs: Double = hourMs, cheapFrom: Double, cheapTo: Double) -> [PriceSlot] { + let start = at(12, 0) + let count = Int((at(14, 0) - start) / dtMs) + return slots(from: start, count: count, dtMs: dtMs) { i in + let t = start + Double(i) * dtMs + return t >= cheapFrom && t < cheapTo ? 5 : 80 + Double(i % 7) + } + } + + @Test func cheapestNextNamesTheTwoHoursAhead() { + let r = read(night(cheapFrom: at(13, 2), cheapTo: at(13, 4)), readAtMs: at(12, 13, 5)) + let evening = PriceGlance.moment(r, atMs: at(12, 20), calendar: calendar) + #expect(evening.cheapest?.startMs == at(13, 2)) + #expect(evening.cheapestText == "Cheapest 02–04") + // The inline line shares its width with the date: the start alone. + #expect(evening.inlineText == "\(evening.priceText!) öre · cheapest 02:00") + // Inside the block it is now, and says until when. + let inside = PriceGlance.moment(r, atMs: at(13, 2, 30), calendar: calendar) + #expect(inside.cheapestText == "Cheapest now until 04") + #expect(inside.cheapestStartText == "cheapest now") + } + + @Test func cheapestFollowsQuarterHours() { + let quarter = Self.hourMs / 4 + let r = read(night(dtMs: quarter, cheapFrom: at(13, 2, 15), cheapTo: at(13, 4, 15)), readAtMs: at(12, 13, 5)) + #expect(PriceGlance.moment(r, atMs: at(12, 20), calendar: calendar).cheapestText == "Cheapest 02:15–04:15") + } + + /// At 14:00, "15–17" would read as this afternoon. Tomorrow's is named. + @Test func tomorrowIsNamedOnlyWhenTheClockWouldMislead() { + let r = read(night(cheapFrom: at(13, 15), cheapTo: at(13, 17)), readAtMs: at(12, 13, 5)) + #expect(PriceGlance.moment(r, atMs: at(12, 14), calendar: calendar).cheapestText == "Cheapest tomorrow 15–17") + #expect(PriceGlance.moment(r, atMs: at(12, 14), calendar: calendar).cheapestStartText == "cheapest tomorrow 15:00") + // The same block seen the next morning is today's. + #expect(PriceGlance.moment(r, atMs: at(13, 9), calendar: calendar).cheapestText == "Cheapest 15–17") + } + + @Test func noCheapestWhenLessThanTwoHoursArePriced() { + let r = read(slots(from: at(12, 0), count: 24), readAtMs: at(12, 8)) + #expect(PriceGlance.moment(r, atMs: at(12, 23, 30), calendar: calendar).cheapestText == nil) + #expect(PriceGlance.moment(r, atMs: at(12, 22, 30), calendar: calendar).cheapestText == "Cheapest now until 24") + } + + @Test func theCurveReachesTwelveHoursAhead() throws { + let r = read(slots(from: at(12, 0), count: 48), readAtMs: at(12, 13, 5)) + let m = PriceGlance.moment(r, atMs: at(12, 13, 30), calendar: calendar) + #expect(m.curve.first?.startMs == at(12, 13)) + #expect(m.curve.first?.current == true) + #expect(m.curve.count == 13) + #expect(try #require(m.curve.last).startMs < at(12, 13, 30) + PriceGlance.curveMs) + } + + // MARK: What the total includes + + /// Core's formula, rounded once on the wire as Core rounds it. + func core(spots: [Double], fee: Double, vat: Double) -> [PriceSlot] { + spots.enumerated().map { i, spot in + PriceSlot(startMs: Double(i) * Self.hourMs, durationMs: Self.hourMs, spotMinor: spot.rounded(), totalMinor: ((spot + fee) * (1 + vat / 100)).rounded()) + } + } + + @Test func theTotalSaysWhatItIncludes() { + let day = (0..<24).map { 30 + 70 * sin(Double($0) / 4) + Double($0) * 0.37 } + #expect(PriceGlance.included(core(spots: day, fee: 45, vat: 25)) == .gridFeeAndVAT) + #expect(PriceGlance.included(core(spots: day, fee: 0, vat: 25)) == .vat) + #expect(PriceGlance.included(core(spots: day, fee: 45, vat: 0)) == .gridFee) + #expect(PriceGlance.included(core(spots: day, fee: 0, vat: 0)) == .spotOnly) + // Cents move little over a day; the line still holds. + let cents = (0..<96).map { 6 + 4 * sin(Double($0) / 12) } + #expect(PriceGlance.included(core(spots: cents, fee: 3, vat: 24)) == .gridFeeAndVAT) + #expect(PriceGlance.included(core(spots: cents, fee: 0, vat: 24)) == .vat) + // A flat market cannot tell a fee from a percentage. + #expect(PriceGlance.included(core(spots: Array(repeating: 50, count: 24), fee: 45, vat: 25)) == .surcharges) + #expect(PriceGlance.text(.gridFeeAndVAT) == "incl. grid fee and VAT") + #expect(PriceGlance.text(.spotOnly) == "spot price only") + } + + // MARK: Freshness + + /// A record read back is the cache, whatever carried it and however + /// healthy the box was then. No moment claims live. + @Test func aCachedPriceNeverLooksLive() { + let r = read(slots(from: at(12, 0), count: 48), readAtMs: at(12, 13, 5), carrier: .relay, srcState: .live) + let moments = PriceGlance.moments(r, nowMs: at(12, 13, 30), calendar: calendar) + for m in moments { + #expect(m.carrier == .cache) + #expect(m.srcState == .live) + #expect(m.readAtMs == at(12, 13, 5)) + let words = [m.priceText, m.cheapestText, m.emptyText, m.fetchedText, m.includedText, m.inlineText].compactMap { $0 } + #expect(!words.contains { $0.lowercased().contains("live") }) + } + #expect(moments.first?.fetchedText == "Fetched 13:05") + // An answer the box itself called short is kept as such. + let short = read(slots(from: at(12, 0), count: 24), readAtMs: at(12, 8), srcState: .stale) + #expect(PriceGlance.moment(short, atMs: at(12, 9), calendar: calendar).srcState == .stale) + } + + @Test func withNothingCachedTheWidgetAsksForTheApp() { + let moments = PriceGlance.moments(nil, nowMs: at(12, 9), calendar: calendar) + #expect(moments.count == 1) + #expect(moments[0].state == .nothing) + #expect(moments[0].carrier == .none) + #expect(moments[0].srcState == .never) + #expect(moments[0].emptyText == "Open FTW to see prices") + } + + // MARK: Words + + /// Every sentence a widget shows has a Swedish line with the same + /// placeholders. + @Test func everyWidgetWordHasSwedish() throws { + let url = try #require(Words.bundle.url(forResource: "Localizable", withExtension: "strings", subdirectory: nil, localization: "sv")) + let table = try #require(NSDictionary(contentsOf: url) as? [String: String]) + for word in GlanceWord.allCases { + let sv = try #require(table[word.rawValue], "no Swedish for \"\(word.rawValue)\"") + #expect(sv.components(separatedBy: "%@").count == word.rawValue.components(separatedBy: "%@").count) + #expect(sv != word.rawValue) + } + } +} diff --git a/appleApp/FTWKit/Tests/FTWSharedTests/PriceRefreshTests.swift b/appleApp/FTWKit/Tests/FTWSharedTests/PriceRefreshTests.swift new file mode 100644 index 0000000..f4d3f59 --- /dev/null +++ b/appleApp/FTWKit/Tests/FTWSharedTests/PriceRefreshTests.swift @@ -0,0 +1,40 @@ +import Foundation +import Testing +@testable import FTWShared + +/// When the app asks iOS to wake it for tomorrow's prices. +@Suite struct PriceRefreshTests { + let calendar: Calendar = { + var c = Calendar(identifier: .gregorian) + c.timeZone = TimeZone(identifier: "Europe/Stockholm")! + return c + }() + + func at(_ day: Int, _ hour: Int, _ minute: Int = 0) -> Double { + calendar.date(from: DateComponents(year: 2026, month: 10, day: day, hour: hour, minute: minute))!.timeIntervalSince1970 * 1000 + } + + @Test func beforeTheMarketPublishesItWaitsForFivePastOne() { + #expect(PriceRefresh.next(afterMs: at(12, 8), pricesEndMs: at(13, 0), calendar: calendar) == at(12, 13, 5)) + } + + @Test func withTomorrowInHandTheNextIsTomorrows() { + #expect(PriceRefresh.next(afterMs: at(12, 13, 20), pricesEndMs: at(14, 0), calendar: calendar) == at(13, 13, 5)) + } + + /// The box asks the market once an hour, so at 13:05 it may not have + /// tomorrow yet. Ask again in half an hour, until the evening. + @Test func withoutTomorrowItAsksAgainSoon() { + #expect(PriceRefresh.next(afterMs: at(12, 13, 10), pricesEndMs: at(13, 0), calendar: calendar) == at(12, 13, 40)) + #expect(PriceRefresh.next(afterMs: at(12, 13, 10), pricesEndMs: nil, calendar: calendar) == at(12, 13, 40)) + #expect(PriceRefresh.next(afterMs: at(12, 19, 50), pricesEndMs: at(13, 0), calendar: calendar) == at(13, 13, 5)) + } + + /// 13:05 on the household's clock, also across the night the clocks go + /// back. + @Test func fivePastOneIsWallClockAcrossTheClockChange() { + let next = PriceRefresh.next(afterMs: at(24, 14), pricesEndMs: at(26, 0), calendar: calendar) + #expect(next == at(25, 13, 5)) + #expect(next - at(24, 13, 5) == 25 * 3_600_000) + } +} diff --git a/appleApp/FTWKit/Tests/FTWSharedTests/SharedFormatTests.swift b/appleApp/FTWKit/Tests/FTWSharedTests/SharedFormatTests.swift new file mode 100644 index 0000000..b9591da --- /dev/null +++ b/appleApp/FTWKit/Tests/FTWSharedTests/SharedFormatTests.swift @@ -0,0 +1,109 @@ +import Foundation +import Testing +@testable import FTWShared + +/// The formatters a widget needs, pinned to the web app's own cases. These +/// read English: the tests run in the development language. +@Suite struct SharedFormatTests { + @Test func powerNeverShowsARawMinus() { + for w in [-1_000_000.0, -4200, -60, 0, 60, 4200] { + #expect(!PowerFormat.parts(w).text.hasPrefix("-")) + } + #expect(PowerFormat.parts(4200).direction == .into) + #expect(PowerFormat.parts(-4200).direction == .out) + #expect(PowerFormat.direction(PowerFormat.noiseW - 1) == .idle) + #expect(PowerFormat.direction(-PowerFormat.noiseW) == .out) + #expect(PowerFormat.parts(9900).text == "9.9") + #expect(PowerFormat.parts(11_400).text == "11") + #expect(PowerFormat.scale(5000) == "5 kW") + #expect(PowerFormat.scale(1500) == "1.5 kW") + #expect(PowerFormat.scale(-5000) == PowerFormat.scale(5000)) + #expect(PowerFormat.scale(.nan) == "") + #expect(PowerFormat.soc(875) == "88") + #expect(PowerFormat.soc(.nan) == "—") + #expect(PowerFormat.age(0) == "just now") + #expect(PowerFormat.age(30_000) == "30s ago") + #expect(PowerFormat.age(90_000) == "1 min ago") + #expect(PowerFormat.age(3_600_000) == "1 h ago") + #expect(PowerFormat.age(nil) == "unknown") + } + + @Test func pricesInTheChartsUnit() { + #expect(PriceUnits.text(144, "SEK") == "144.0") + #expect(PriceUnits.unit("EUR").perKwh == "cent/kWh") + #expect(PriceUnits.text(400, "CZK") == "4.00") + #expect(PriceUnits.unit("XYZ").perKwh == "XYZ/kWh") + let day: Double = 1_750_000_000_000 + let slots = [PriceSlot(startMs: day, durationMs: 3_600_000, spotMinor: 1, totalMinor: 1), PriceSlot(startMs: day + 7_200_000, durationMs: 3_600_000, spotMinor: 1, totalMinor: 1)] + #expect(PriceUnits.hasHole(slots, fromMs: day)) + #expect(PriceUnits.hasHole(Array(slots.suffix(1)), fromMs: day)) + #expect(!PriceUnits.hasHole(Array(slots.prefix(1)), fromMs: day)) + } + + @Test func theBandMatchesTheWebAppsCases() { + func band(_ carrier: CarrierKind, transport: CarrierKind = .none, _ src: SourceState, age: Double?, _ phase: SessionPhase, wait: Double = 0, boot: Int? = nil, noCarrier: Bool = false) -> Freshness.Band { + Freshness.band(carrier: carrier, transport: transport, srcState: src, ageMs: age, phase: phase, waitMs: wait, bootPct: boot, noCarrier: noCarrier) + } + // A decision about the phone is not a connection fault. + let ended = band(.none, .stale, age: 7_200_000, .terminated) + #expect(ended.message == "Access ended") + // No carrier at all: no promise of a retry. + let lost = band(.none, .stale, age: 60_000, .failed, noCarrier: true) + #expect(lost.message == "Can't reach your box") + #expect(lost.tone == .lost) + #expect(lost.wait == nil) + // A carrier healing itself says so, with its elapsed time. + let healing = band(.none, .stale, age: 60_000, .failed, wait: 7_000) + #expect(healing.message == "Reconnecting to your box") + #expect(healing.wait == "7s") + #expect(healing.age == "1 min ago") + // Live only for live readings. + let quiet = band(.relay, .stale, age: 5_000, .streaming) + #expect(quiet.message == "Encrypted relay connected · readings") + #expect(quiet.tone == .stale) + #expect(!quiet.message.lowercased().contains("live")) + #expect(band(.relay, .live, age: 0, .streaming).message == "Live via encrypted relay") + #expect(band(.relay, .live, age: 0, .streaming).age == nil) + // A starting box shows its own progress. + let booting = band(.none, transport: .relay, .stale, age: 5_000, .booting, boot: 40) + #expect(booting.message == "Your box is starting") + #expect(booting.wait == "40%") + // After a restart the box cannot place the reading: a dash, never a guess. + #expect(band(.cache, transport: .relay, .stale, age: nil, .handshaking).age == "—") + #expect(band(.cache, transport: .relay, .stale, age: nil, .handshaking).message == "Securing encrypted relay") + } + + /// A relay that turned this phone away is busy, not broken, and has no + /// countdown: waiting longer does not help. + @Test func aFullRelayIsBusy() { + let busy = Freshness.band(carrier: .none, transport: .relay, srcState: .stale, ageMs: 60_000, phase: .failed, waitMs: 9_000, bootPct: nil, noCarrier: false, busy: true) + #expect(busy.tone == .busy) + #expect(busy.message == "FTW is busy") + #expect(busy.wait == nil) + #expect(busy.age == "1 min ago") + // Once a carrier streams again, busy no longer applies. + let live = Freshness.band(carrier: .relay, transport: .relay, srcState: .live, ageMs: 0, phase: .streaming, waitMs: 0, bootPct: nil, noCarrier: false, busy: true) + #expect(live.tone == .live) + } + + @Test func thePriceCardFindsTheCheapestTwoHoursAhead() { + let hour = 3_600_000.0 + let day = 1_750_000_000_000.0 + let totals: [Double] = [100, 120, 40, 30, 35, 200, -5, 90] + let slots = totals.enumerated().map { PriceSlot(startMs: day + Double($0.offset) * hour, durationMs: hour, spotMinor: $0.element, totalMinor: $0.element) } + let prices = Prices(zone: "SE3", currency: "SEK", slots: slots, stale: false) + let s = PriceStrip.summary(prices, nowMs: day + 1.5 * hour) + #expect(s.current?.totalMinor == 120) + // The slot already over is not ahead. + #expect(s.bars.count == 7) + #expect(s.bars.first?.current == true) + #expect(s.bars.first(where: { $0.minor == -5 })?.tone == .negative) + #expect(s.bars.first(where: { $0.minor == 200 })?.tone == .dear) + #expect(s.bars.first(where: { $0.minor == 30 })?.tone == .cheap) + // 30 then 35 beats 40 then 30: two whole hours in a row, the lowest mean. + #expect(s.cheapest == PriceStrip.Block(meanMinor: 32.5, startMs: day + 3 * hour, endMs: day + 5 * hour)) + #expect(PriceStrip.text(144.4, "SEK") == "144") + #expect(PriceStrip.text(14.44, "SEK") == "14.4") + #expect(PriceStrip.text(nil, "SEK") == "—") + } +} diff --git a/appleApp/FTWWidgets-Info.plist b/appleApp/FTWWidgets-Info.plist new file mode 100644 index 0000000..f62f91d --- /dev/null +++ b/appleApp/FTWWidgets-Info.plist @@ -0,0 +1,18 @@ + + + + + NSExtension + + NSExtensionPointIdentifier + com.apple.widgetkit-extension + + FTWAppGroup + $(FTW_APP_GROUP) + CFBundleLocalizations + + en + sv + + + diff --git a/appleApp/FTWWidgets.entitlements b/appleApp/FTWWidgets.entitlements new file mode 100644 index 0000000..04c6e8a --- /dev/null +++ b/appleApp/FTWWidgets.entitlements @@ -0,0 +1,10 @@ + + + + + com.apple.security.application-groups + + $(FTW_APP_GROUP) + + + diff --git a/appleApp/FTWWidgets/PriceViews.swift b/appleApp/FTWWidgets/PriceViews.swift new file mode 100644 index 0000000..f2c8d91 --- /dev/null +++ b/appleApp/FTWWidgets/PriceViews.swift @@ -0,0 +1,299 @@ +import Charts +import FTWShared +import SwiftUI +import WidgetKit + +/// One moment of the price widget, in whichever family it sits. Every +/// sentence comes from `PriceGlance`; this file only lays them out. +struct PriceWidgetView: View { + let moment: PriceGlance.Moment + @Environment(\.widgetFamily) private var family + + var body: some View { + content + .containerBackground(for: .widget) { Rectangle().fill(background) } + } + + /// Grey when there is no price for this moment. The lock screen draws + /// its own, so the accessory families bring none. + private var background: AnyShapeStyle { + switch family { + case .accessoryRectangular, .accessoryInline, .accessoryCircular: return AnyShapeStyle(.clear) + default: return AnyShapeStyle(moment.isEmpty ? Tone.empty : Tone.surface) + } + } + + @ViewBuilder private var content: some View { + switch family { + case .systemMedium: MediumPrice(moment: moment) + case .accessoryRectangular: RectangularPrice(moment: moment) + case .accessoryInline: Label(moment.inlineText, systemImage: Header.symbol) + default: SmallPrice(moment: moment) + } + } +} + +/// The home screen's small widget, and StandBy's. +struct SmallPrice: View { + let moment: PriceGlance.Moment + + var body: some View { + VStack(alignment: .leading, spacing: 2) { + if let price = moment.priceText { + Header(text: PriceGlance.priceNow) + PriceFigure(price: price, size: 38) + Text(moment.unitText).font(.caption2).foregroundStyle(.secondary) + Text(moment.includedText).font(.caption2).foregroundStyle(.secondary).lineLimit(1).minimumScaleFactor(0.8) + Spacer(minLength: 4) + Cheapest(moment: moment) + Fetched(moment: moment) + } else { + Empty(moment: moment) + } + } + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading) + .accessibilityElement(children: .combine) + } +} + +/// The figures on the left and the next twelve hours on the right. +struct MediumPrice: View { + let moment: PriceGlance.Moment + + var body: some View { + if let price = moment.priceText { + HStack(alignment: .top, spacing: 12) { + VStack(alignment: .leading, spacing: 2) { + Header(text: PriceGlance.priceNow) + PriceFigure(price: price, size: 34) + Text(moment.unitText).font(.caption2).foregroundStyle(.secondary) + Text(moment.includedText).font(.caption2).foregroundStyle(.secondary).lineLimit(2).minimumScaleFactor(0.8) + Spacer(minLength: 4) + Cheapest(moment: moment) + Fetched(moment: moment) + } + .frame(width: 128, alignment: .leading) + .accessibilityElement(children: .combine) + Curve(moment: moment) + } + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading) + } else { + VStack(alignment: .leading, spacing: 2) { Empty(moment: moment) } + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading) + } + } +} + +/// The lock screen's rectangle: price, cheapest, what the price holds. +struct RectangularPrice: View { + let moment: PriceGlance.Moment + + var body: some View { + VStack(alignment: .leading, spacing: 0) { + if let price = moment.priceText { + Text("\(Image(systemName: Header.symbol)) \(Text(price).font(.headline.monospacedDigit())) \(Text(moment.unitText).font(.caption))") + .contentTransition(.numericText()) + .widgetAccentable() + if let cheapest = moment.cheapestText { + Text(cheapest).font(.caption).lineLimit(1).minimumScaleFactor(0.75) + } + Text(moment.includedText).font(.caption2).foregroundStyle(.secondary).lineLimit(1).minimumScaleFactor(0.7) + } else { + Text(moment.emptyText ?? "").font(.caption).foregroundStyle(.secondary).lineLimit(3) + } + } + .frame(maxWidth: .infinity, alignment: .leading) + .accessibilityElement(children: .combine) + } +} + +// MARK: Parts + +/// "⚡ Pris nu": the symbol takes the accent colour, the words stay quiet. +struct Header: View { + static let symbol = "bolt.fill" + let text: String + + var body: some View { + HStack(spacing: 3) { + Image(systemName: Self.symbol) + .imageScale(.small) + .foregroundStyle(Tone.accent) + .widgetAccentable() + Text(text).foregroundStyle(.secondary) + } + .font(.caption) + } +} + +struct PriceFigure: View { + let price: String + let size: CGFloat + + var body: some View { + Text(price) + .font(.system(size: size, weight: .semibold, design: .rounded).monospacedDigit()) + .contentTransition(.numericText()) + .minimumScaleFactor(0.5) + .lineLimit(1) + .widgetAccentable() + } +} + +struct Cheapest: View { + let moment: PriceGlance.Moment + + var body: some View { + if let text = moment.cheapestText { + Text(text) + .font(.caption.weight(.semibold)) + .foregroundStyle(Tone.cheap) + .lineLimit(1) + .minimumScaleFactor(0.7) + .widgetAccentable() + } + } +} + +/// When the app fetched these prices. A widget only ever shows the cache. +struct Fetched: View { + let moment: PriceGlance.Moment + + var body: some View { + if let text = moment.fetchedText { + Text(text).font(.caption2).foregroundStyle(.tertiary).lineLimit(1) + } + } +} + +/// No price for this moment: say why and send the reader to the app. +struct Empty: View { + let moment: PriceGlance.Moment + + var body: some View { + Header(text: PriceGlance.title) + Spacer(minLength: 4) + Text(moment.emptyText ?? "") + .font(.footnote.weight(.medium)) + .foregroundStyle(.secondary) + .fixedSize(horizontal: false, vertical: true) + Spacer(minLength: 4) + Fetched(moment: moment) + } +} + +/// The next twelve hours as bars from zero, at the resolution the box sent: +/// green below the average ahead, red above, the current slot solid. +/// +/// Tinted and clear home screens and StandBy at night draw in one colour, so +/// there the current slot and the cheap ones stand out and the rest are faint. +struct Curve: View { + let moment: PriceGlance.Moment + @Environment(\.widgetRenderingMode) private var renderingMode + + var body: some View { + Chart { + ForEach(moment.curve) { bar in + mark(bar) + } + if let mean = moment.meanMinor { + RuleMark(y: .value("Average", PriceUnits.display(mean, moment.currency))) + .lineStyle(StrokeStyle(lineWidth: 1, dash: [2, 3])) + .foregroundStyle(Tone.flat) + } + } + .chartXScale(domain: date(start)...date(end)) + .chartXAxis { + AxisMarks(values: hours) { value in + AxisValueLabel { + if let d = value.as(Date.self) { Text(Self.hour(d)) } + } + } + } + .chartYAxis(.hidden) + .accessibilityHidden(true) + } + + /// A bar from zero, as wide as the slot. A rectangle, because a bar + /// mark with an x interval and one y lies on its side. + private func mark(_ bar: PriceStrip.Bar) -> some ChartContent { + let price: Double = PriceUnits.display(bar.minor, moment.currency) + return RectangleMark( + xStart: .value("From", date(bar.startMs)), + xEnd: .value("To", date(bar.endMs)), + yStart: .value("Zero", 0.0), + yEnd: .value("Price", price) + ) + .foregroundStyle(renderingMode == .fullColor ? AnyShapeStyle(Tone.of(bar.tone)) : AnyShapeStyle(.primary)) + .opacity(opacity(bar)) + } + + private func opacity(_ bar: PriceStrip.Bar) -> Double { + if bar.current { return 1 } + guard renderingMode != .fullColor else { return 0.55 } + return bar.tone == .cheap || bar.tone == .negative ? 0.8 : 0.3 + } + + private var start: Double { moment.curve.first?.startMs ?? moment.atMs } + private var end: Double { moment.curve.last?.endMs ?? moment.atMs } + + /// Every third whole hour, kept clear of the right edge so no label is + /// cut short. + private var hours: [Date] { + let hour: Double = 3_600_000 + var t = (start / hour).rounded(.up) * hour + var out: [Date] = [] + while t < end - hour { + if Calendar.current.component(.hour, from: date(t)) % 3 == 0 { out.append(date(t)) } + t += hour + } + return out + } + + /// "02", on the 24-hour clock the rest of the widget uses. + private static func hour(_ d: Date) -> String { + String(format: "%02d", Calendar.current.component(.hour, from: d)) + } + + private func date(_ ms: Double) -> Date { Date(timeIntervalSince1970: ms / 1000) } +} + +/// The app's colour roles (FTW/UI/Theme.swift, from the web app's tokens), +/// the few a price widget needs. +enum Tone { + static let surface = Pair(0xFAFAF8, 0x161616) + static let empty = Pair(0xDCDCD6, 0x2A2A2A) + static let cheap = Pair(0x009639, 0x5FD37F) + static let dear = Pair(0xCC243D, 0xFF6E74) + static let negative = Pair(0xD48500, 0xFFB000) + static let flat = Pair(0x686862, 0x858585) + static let accent = Pair(0xDA7F00, 0xFFAC41) + + static func of(_ tone: PriceStrip.Tone) -> Pair { + switch tone { + case .cheap: return cheap + case .dear: return dear + case .flat: return flat + case .negative: return negative + } + } +} + +/// One colour for light and one for dark, picked by SwiftUI where it draws. +struct Pair: ShapeStyle { + let light: Color + let dark: Color + + init(_ light: UInt32, _ dark: UInt32) { + self.light = Self.color(light) + self.dark = Self.color(dark) + } + + func resolve(in environment: EnvironmentValues) -> Color { + environment.colorScheme == .dark ? dark : light + } + + private static func color(_ rgb: UInt32) -> Color { + Color(red: Double((rgb >> 16) & 0xFF) / 255, green: Double((rgb >> 8) & 0xFF) / 255, blue: Double(rgb & 0xFF) / 255) + } +} diff --git a/appleApp/FTWWidgets/PriceWidget.swift b/appleApp/FTWWidgets/PriceWidget.swift new file mode 100644 index 0000000..bb50c0f --- /dev/null +++ b/appleApp/FTWWidgets/PriceWidget.swift @@ -0,0 +1,80 @@ +import FTWShared +import os +import SwiftUI +import WidgetKit + +private let log = Logger(subsystem: "energy.ftw.app", category: "widgets") +private let signposter = OSSignposter(subsystem: "energy.ftw.app", category: "widgets") + +@main +struct FTWWidgets: WidgetBundle { + var body: some Widget { + PriceWidget() + } +} + +/// The price now, when it is cheapest next, and a short curve, from what the +/// app left in the App Group. A widget opens no connection and holds no key. +/// Prices are not secret, so the lock screen shows them as they are. +struct PriceWidget: Widget { + var body: some WidgetConfiguration { + StaticConfiguration(kind: "energy.ftw.prices", provider: PriceTimeline()) { entry in + PriceWidgetView(moment: entry.moment) + } + .configurationDisplayName(PriceGlance.title) + .description(PriceGlance.summary) + // systemSmall is also the StandBy widget. + .supportedFamilies([.systemSmall, .systemMedium, .accessoryRectangular, .accessoryInline]) + } +} + +struct PriceWidgetEntry: TimelineEntry { + let date: Date + let moment: PriceGlance.Moment +} + +struct PriceTimeline: TimelineProvider { + func placeholder(in context: Context) -> PriceWidgetEntry { + // Drawn redacted while the real entry loads, so only the shape shows. + let now = Date.now.timeIntervalSince1970 * 1000 + return PriceWidgetEntry(date: .now, moment: PriceGlance.moment(Self.sample(nowMs: now), atMs: now)) + } + + func getSnapshot(in context: Context, completion: @escaping (PriceWidgetEntry) -> Void) { + completion(entries(now: .now)[0]) + } + + /// The app reloads every timeline after it writes, so nothing here asks + /// to be woken. The last entry falls where the prices end and stays. + func getTimeline(in context: Context, completion: @escaping (Timeline) -> Void) { + let interval = signposter.beginInterval("price timeline") + let started = ContinuousClock.now + let entries = entries(now: .now) + let ms = (ContinuousClock.now - started) / .milliseconds(1) + signposter.endInterval("price timeline", interval) + if let last = entries.last?.moment { + log.info("price timeline: \(entries.count, privacy: .public) entries in \(ms, format: .fixed(precision: 1), privacy: .public) ms, last \(String(describing: last.state), privacy: .public)") + } + completion(Timeline(entries: entries, policy: .never)) + } + + private func entries(now: Date) -> [PriceWidgetEntry] { + let reader = CacheReader.shared() + let read = reader.flatMap { r in r.latestSiteID(PriceEntry.self).flatMap { r.read(PriceEntry.self, siteId: $0) } } + if reader == nil { log.error("price widget: no App Group, nothing to read") } + return PriceGlance.moments(read, nowMs: now.timeIntervalSince1970 * 1000) + .map { PriceWidgetEntry(date: Date(timeIntervalSince1970: $0.atMs / 1000), moment: $0) } + } + + /// A day's shape for the redacted placeholder. Never shown as a price. + static func sample(nowMs: Double) -> CacheRead { + let hour: Double = 3_600_000 + let start = (nowMs / hour).rounded(.down) * hour + let slots = (0..<12).map { i in + PriceSlot(startMs: start + Double(i) * hour, durationMs: hour, spotMinor: 40, totalMinor: 60 + Double((i * 37) % 50)) + } + let prices = Prices(zone: "", currency: "SEK", slots: slots, stale: false) + let record = CacheRecord(siteId: "", carrier: .none, srcState: .never, readAtMs: nowMs, value: PriceEntry(fromMs: start, prices: prices)) + return CacheRead(record: record, origin: .app) + } +} diff --git a/appleApp/FTWWidgets/PrivacyInfo.xcprivacy b/appleApp/FTWWidgets/PrivacyInfo.xcprivacy new file mode 100644 index 0000000..64e785c --- /dev/null +++ b/appleApp/FTWWidgets/PrivacyInfo.xcprivacy @@ -0,0 +1,23 @@ + + + + + NSPrivacyTracking + + NSPrivacyTrackingDomains + + NSPrivacyCollectedDataTypes + + NSPrivacyAccessedAPITypes + + + NSPrivacyAccessedAPIType + NSPrivacyAccessedAPICategoryFileTimestamp + NSPrivacyAccessedAPITypeReasons + + C617.1 + + + + + diff --git a/appleApp/RELEASING.md b/appleApp/RELEASING.md new file mode 100644 index 0000000..f27df30 --- /dev/null +++ b/appleApp/RELEASING.md @@ -0,0 +1,445 @@ +# Releasing the iPhone app + +CI tests, archives, signs and uploads. A person sets up App Store Connect +once, then tags each release. This file lists that person's steps in order: +first the shortest way onto your own iPhone, then what external testers +and the App Store need. Checked against Apple's pages on 12 October 2026; +Apple moves menus, so trust the screen if it differs. + +| What | Value | +|---|---| +| Seller | Sourceful Labs AB (reg. no. 559382-0458), Kalmar, Sweden | +| Team ID | `L66M368PZ6` | +| Bundle IDs | `energy.ftw.app` (registered), `energy.ftw.app.widgets` | +| App Group | `group.energy.ftw.app` | +| Workflow | [`.github/workflows/ios-release.yml`](../.github/workflows/ios-release.yml) | +| Store texts | [`store/`](store/) | +| Privacy and support | `https://ftw.sourceful.energy/privacy`, `https://ftw.sourceful.energy/support` ([srcfl/ftw-web](https://github.com/srcfl/ftw-web)) | + +## How a build travels + +1. A tag `ios-v1.2.3` starts the job on GitHub's `xcode-27` runner. The job + uses the workflow file in the tagged commit, so any branch that contains + it can be tagged. Text after a dash only tells tags apart: + `ios-v0.1.0-1` and `ios-v0.1.0-2` both ship version 0.1.0. Once the + workflow is on `main`, **Actions → iOS release → Run workflow** also + works; it ships `MARKETING_VERSION` from `FTW.xcconfig`. +2. It runs `swift test`, then archives with Xcode 27.0 and the iOS 27 SDK. + The build number is the run number, with `.2`, `.3` on a re-run. +3. Xcode signs with the team through the API key and uploads with + [`ExportOptions.plist`](ExportOptions.plist) (`app-store-connect`, + `upload`). If that upload fails, the job exports the `.ipa` and sends it + with `xcrun altool --upload-package`, which Apple's "Upload builds" help + still lists. The build shows in TestFlight once Apple has processed it. + +Without the three secrets the job archives unsigned and reports +"Upload skipped". A pull request that changes the workflow or the export +options always takes that path, so it proves the archive without a key. + +Apple has required the iOS 26 SDK for uploads since 28 April 2026 and +requires the iOS 27 SDK from April 2027. Xcode 27 meets both. + +## Your own iPhone, from Xcode + +No App Store Connect, no key. The app is signed for development and runs +until the profile expires. + +1. On the iPhone, connect it to the Mac once, then **Settings → Privacy & + Security → Developer Mode → On**. The iPhone restarts; confirm. +2. In Xcode: **Settings → Accounts → (+) → Apple Account**, and sign in + with an Apple Account in team `L66M368PZ6`. +3. Unlock the iPhone and tap **Trust** when it asks. + +The simplest way: open `appleApp/FTW.xcodeproj`, pick the iPhone above the +editor, press **⌘R**. From the command line instead, find the iPhone's +UDID. Run these from the repository's root: + +```bash +xcrun devicectl list devices +``` + +Build for it. Automatic signing registers the iPhone and the widget's App +ID in the team: + +```bash +xcodebuild build -project appleApp/FTW.xcodeproj -scheme FTW -configuration Debug -destination 'platform=iOS,id=' -derivedDataPath appleApp/build/device -allowProvisioningUpdates +``` + +Install and open it: + +```bash +xcrun devicectl device install app --device appleApp/build/device/Build/Products/Debug-iphoneos/FTW.app +``` + +```bash +xcrun devicectl device process launch --device energy.ftw.app +``` + +## Internal TestFlight, the shortest way + +Internal testers are people in the App Store Connect team, up to 100. +Their builds need no Beta App Review. + +### 1. Accept the agreement + +The Account Holder accepts the latest Apple Developer Program License +Agreement under **Business**. Until then App Store Connect refuses new +apps, and the API key stops working. A free app needs no Paid Apps +Agreement. + +### 2. Create the app + +**App Store Connect → Apps → (+) → New App** + +| Field | Enter | +|---|---| +| Platforms | iOS | +| Name | `FTW` (see below) | +| Primary Language | English (U.S.) | +| Bundle ID | `energy.ftw.app` | +| SKU | `energy.ftw.app` (cannot change later) | +| User Access | Full Access | + +English is the primary language, so every country without its own text +shows English. Swedish comes later, under the localization menu in App +Information. + +**The name.** The iTunes Search API found no live app named exactly "FTW" +in the Swedish or US store on 12 October 2026 (nearest: "FTW Fly", "FTW - +Step Competition", "FTW Fitness", "FTW Trading"). The search sees only +live apps, and Apple also holds names for apps not yet released, so the New +App dialog gives the final answer. If "FTW" is taken, try in this order; +none was in use: + +1. `FTW Energy` (Swedish page: `FTW Energi`) +2. `FTW – Home Energy` (`FTW – Hemmets energi`) +3. `FTW by Sourceful` + +The name under the icon stays "FTW" whatever the store name. + +### 3. Make the API key and add the secrets + +**Users and Access → Integrations → App Store Connect API → Team Keys → +Generate API Key** + +- Name: `GitHub ftw-app release`. +- Access: **Admin**. With an App Manager key, cloud signing fails + ("Cloud signing permission error"; [Apple forum + thread](https://developer.apple.com/forums/thread/698117)). The reason: + the runner has no certificate, so the export signs with a cloud-managed + distribution certificate. Apple's [roles + table](https://developer.apple.com/support/roles/) gives those to Account + Holder and Admin; other roles need a per-user permission, and a team key + cannot carry one. Role and name cannot change after creation. +- Download the `.p8` file. Apple offers it once. +- The Key ID is in the key's row; the Issuer ID is at the top of the page. + +`ASC_KEY_ID` and `ASC_ISSUER_ID` are plain text; `gh` asks for each. +`ASC_KEY_P8` is the `.p8` file's PEM text as it is, not base64: + +```bash +gh secret set ASC_KEY_ID --repo srcfl/ftw-app +``` + +```bash +gh secret set ASC_ISSUER_ID --repo srcfl/ftw-app +``` + +```bash +gh secret set ASC_KEY_P8 --repo srcfl/ftw-app < AuthKey_XXXXXXXXXX.p8 +``` + +Then delete the `.p8` from your disk, or keep it in a password manager. + +**Safer, when there is time.** An Admin key can do much more than upload +builds, and any branch's workflow can read a repository secret. The job +also reads secrets from an environment `app-store`: + +1. **srcfl/ftw-app → Settings → Environments → New environment → + `app-store`.** +2. **Deployment branches and tags → Selected branches and tags**: add + branch `main` and tag `ios-v*`. Optionally add yourself as a required + reviewer. +3. Set the same three secrets with `--env app-store` added, then delete + the repository ones. + +### 4. Tag a build + +Tag a commit that contains the workflow, for example the head of the +branch you want to test, and push the tag: + +```bash +git tag ios-v0.1.0-1 && git push origin ios-v0.1.0-1 +``` + +Follow the run under **Actions → iOS release**. Each run on a clean runner +may create an "Apple Development" certificate marked "Created via API" for +the archive; if **Certificates** reports a limit, revoke the old ones. + +### 5. Answer the encryption question, if asked + +The store-ready branch ([#13](https://github.com/srcfl/ftw-app/pull/13)) +sets `ITSAppUsesNonExemptEncryption` to `NO`, so builds that carry it skip +this. If a build shows **Missing Compliance** in TestFlight, click +**Manage** next to it and choose **None of the algorithms mentioned +above** ([Encryption and export](#5-encryption-and-export) says why). + +### 6. Add yourself as a tester + +**Apps → FTW → TestFlight → Internal Testing → (+)**: group `Sourceful`. +Add yourself and others from the App Store Connect team, and turn on +automatic distribution so each new build reaches the group. On the iPhone, +install TestFlight from the App Store, signed in with the same Apple +Account, and accept the invitation. + +A build expires after 90 days. + +## Before external testers and the App Store + +### 1. Links + +- `https://app.ftw.energy/.well-known/apple-app-site-association` must + answer before review: passkeys and pairing links need it + ([srcfl/ftw-webapp#77](https://github.com/srcfl/ftw-webapp/pull/77)). + On 12 October 2026 it answered 404. +- `ftw.energy` must serve the website. On 12 October 2026 it had no + address record, and the Cloudflare Pages project `ftw` served only + `ftw.sourceful.energy`. In Cloudflare: **Workers & Pages → ftw → Custom + domains → Set up a custom domain → `ftw.energy`**. Then check: + + ```bash + curl -sI https://ftw.sourceful.energy/privacy + ``` + + If `ftw.energy` stays off, replace it with `ftw.sourceful.energy` in + `store/*/privacy-url.txt`, `support-url.txt` and `marketing-url.txt`. + +### 2. App Information + +**Apps → FTW → App Information** (under General), for each language. Add +**Swedish** from the localization menu first. + +| Field | Value | +|---|---| +| Subtitle | `store//subtitle.txt` | +| Category | Primary **Utilities**, no secondary. Not Health & Fitness: that asks for a medical device declaration. | +| Content Rights | No third-party content: the app shows only the user's own box. | +| Age Rating | **Set Up Age Ratings**. Answer No to every capability (web access, user-generated content, social media, messaging, advertising) and None to every content item. Age Categories and Override: Not Applicable. Result: 4+. | +| Copyright | `2026 Sourceful Labs AB` | +| Privacy Policy URL (App Privacy) | `store//privacy-url.txt` | + +### 3. Trader status under the EU Digital Services Act + +EU users see the trader's address, phone and email on the product page. +Apple removes apps without trader status from the EU stores. + +1. **Business → Agreements → Compliance → Digital Services Act → Complete + Compliance Requirements → This is a trader account.** +2. The address comes from Sourceful Labs AB's D-U-N-S Number. Enter a + phone number and an email address, and confirm each with the code Apple + sends. +3. Upload a document that proves the company name and address, such as a + registration certificate from Bolagsverket. +4. For the app: **App Information → App Store Regulations and Permits → + Digital Services Act → Edit → trader**. + +Sourceful Labs AB already sells "Sourceful Energy" in the EU stores, so +the account may already be a trader account; check step 1 first. If that +app lives under another team, this team needs its own. + +**Decide:** which email and phone EU users see. The only Sourceful address +published today is `support@sourceful.energy` on +[sourceful.energy/contact](https://www.sourceful.energy/contact), which +ftw-web and FTW's SUPPORT.md link to. No phone number is published. + +### 4. App Privacy + +**Apps → FTW → App Privacy → Get Started → "No, we do not collect data +from this app" → Save → Publish.** + +Apple's test: data is collected when it leaves the device "in a way that +allows you and/or your third-party partners to access it for a period +longer than what is necessary to service the transmitted request in real +time". In M1: + +- The app's only network peer is `wss://relay.ftw.energy`. Everything it + sends is end-to-end encrypted to the user's box; the relay has no key. +- The relay keeps no traffic. Its access log drops addresses and + rendezvous handles (`relay/deploy/Caddyfile` in ftw-webapp). +- No analytics, ads, third-party code or tracking. + +One edge: when the relay fails to proxy a connection, Caddy's error log +may still hold the client address and request URI (rotated, 3 × 10 MB). +**Decide** before you publish the answer: + +- remove addresses from that log too, in ftw-webapp, so "Data Not + Collected" holds exactly; or +- declare **Other Diagnostic Data**: not linked to the user, not used for + tracking, purpose App Functionality. + +The privacy pages in srcfl/ftw-web#18 already mention the log. + +The privacy manifests from #13 match this answer. Both the app's and the +widget's declare no tracking, no tracking domains and no collected data. +Required-reason APIs: file timestamps (`C617.1`) in both, since the cache +sorts App Group files by date, and `UserDefaults` (`CA92.1`) in the app. + +Both answers change when FTW Cloud ships: it will hold readable data the +user turns on. + +### 5. Encryption and export + +#13 sets `ITSAppUsesNonExemptEncryption` to `NO` in `FTW-Info.plist`, so +App Store Connect asks nothing per build. A build without it asks under +**App Information → App Encryption Documentation (+)**, or **Manage** next +to the build in TestFlight; answer **None of the algorithms mentioned +above**. + +Why: all cryptography runs in CryptoKit, which ships with iOS (X25519, +ChaCha20-Poly1305, SHA-256 and HKDF, AES-GCM, Ed25519). The app implements +no algorithm itself; Noise IK is a published protocol built on those. +Apple's table says encryption "limited to that within the Apple operating +system" needs no documentation. No Apple page says outright that CryptoKit +counts; that is our reading. + +The rest is #13's reading, which no lawyer has reviewed; its sources are in +the PR. + +- **United States.** The source is public, so the encryption is not + subject to the EAR (15 CFR 742.15(b) and the note to 734.3(b)(2)–(3)): no + email to BIS and the NSA, and no year-end report. That holds only if every + store build comes from a published commit. The workflow passes + `FTW_SOURCE_REVISION=$GITHUB_SHA`; tag only commits pushed to this + public repository. +- **France.** Apple asks for nothing with "None". French law (Décret + 2007-663, art. 3–5) still requires a free declaration to ANSSI one month + before the app is offered in France. Fredrik decided on 12 October 2026 + that France waits: the first release leaves it out, and the declaration + comes later ([Before launching in France](#before-launching-in-france)). + +### 6. Price and countries + +**Monetization → Pricing and Availability** + +1. **Price Schedule → Add Pricing**: base country Sweden, price Free. +2. **App Availability → Set Up Availability → All Countries or Regions** + (175, and new ones as Apple adds them). +3. Remove **China mainland**. It needs an ICP Filing Number in App + Information that matches a filing with China's MIIT, which in practice + takes a Chinese entity or partner. Without it the version stops at + "ICP Filing Number Missing". (The iTunes lookup shows "Sourceful + Energy" in the Chinese store. If Sourceful holds a filing, enter it and + keep China.) +4. Remove **France** until the ANSSI declaration is filed and a month has + passed ([Before launching in France](#before-launching-in-france)). +5. Untick **Apple silicon Mac** and **Apple Vision Pro**. Apple publishes + iPhone apps there by default; M1 is iPhone only. + +Other countries, checked for a free 4+ utility that is not a game: + +| Where | Rule | For FTW | +|---|---|---| +| South Korea, Vietnam | Game ratings and licences | Games only. Nothing to do. | +| Brazil | Tax forms | Only for developers in Brazil. | +| Australia, Brazil, Singapore | 18+ download checks | Not for a 4+ app. | +| Texas, Utah, Louisiana | Age assurance for new accounts (Declared Age Range API) | **Decide with counsel.** M1 has no accounts and no age-dependent content. | +| France | ANSSI encryption declaration one month ahead (#13's reading) | Left out of the first release (step 4). | +| Russia | — | "Sourceful Energy" is not in the Russian store. **Decide** whether FTW follows. | + +### 7. External TestFlight + +**Apps → FTW → TestFlight** + +1. **Additional → Test Information**, for each language: + - Beta App Description: the first paragraph of `store//description.txt`. + - Feedback Email: an inbox someone reads (see the decision in step 3). + - Marketing URL and Privacy Policy URL: from `store//`. + - Beta App Review Information: contact, "Sign-in required" off, and + `store/review-notes.txt` as notes. +2. **External Testing → (+)**: group `Beta`. An internal group must exist + first. Add the build, then **Submit for Review**. Apple reviews the + first build; later builds of the same version usually go out at once. + Invite by email or turn on a public link (Open to Anyone, or Filter by + Criteria, with a tester limit). Up to 10,000 testers. +3. For each build, **What to Test**: `store//release-notes.txt`. + +### 8. The App Store version + +**Apps → FTW → App Store → iOS App → the version** + +| Field | Source | +|---|---| +| iPhone screenshots | `scripts/store-screenshots.sh` (6.9", 1320 × 2868) | +| Promotional Text | `store//promotional-text.txt` | +| Description | `store//description.txt` | +| Keywords | `store//keywords.txt` | +| Support URL | `store//support-url.txt` | +| Marketing URL | `store//marketing-url.txt` | +| What's New | `store//release-notes.txt` (not shown for the first version) | +| Build | The one TestFlight tested | +| App Review Information | Sign-in required: off. Contact: name, email, phone with `+46`. Notes: `store/review-notes.txt` | +| Version Release | Manually release this version | + +Then **Add for Review → Submit**. + +Guideline 2.1(a) allows a demo mode "in lieu of a demo account with prior +approval by Apple". FTW has no login; the demo stands in for the box. The +notes say so. If review asks for a video of a real box, record pairing +and the car view. + +`store/check.sh` checks every text against Apple's limits. + +## Each release + +1. Set `MARKETING_VERSION` in `FTW.xcconfig` to the new version on `main`. +2. Tag that commit and push the tag: + + ```bash + git tag ios-v1.0.0 && git push origin ios-v1.0.0 + ``` + +3. Watch the *iOS release* run. When it reports the upload, the build + appears in TestFlight, usually within an hour. +4. Write What to Test, then hand the build to the groups. + +## When the upload fails + +The run keeps Xcode's export logs as the `export-logs` artifact. The +summary says whether Xcode or altool uploaded the build. + +| Message | Cause | +|---|---| +| Cloud signing permission error | The key is not Admin. | +| No profiles for 'energy.ftw.app' | App ID not registered, or no app record yet. | +| Private key not installed for an Apple Development certificate | Old API certificates; revoke them under Certificates. | +| The bundle version must be higher | A build number was used; run again or tag again. | +| Agreement missing or expired | The Account Holder must accept it under Business. | +| Xcode upload failed, then altool succeeded | Nothing to do; Apple got the build. | + +## Before launching in France + +French law asks the supplier of an encryption product to declare it to +ANSSI, France's cybersecurity agency, one month before offering it there +(#13's reading of Décret 2007-663; no lawyer has reviewed it). The +declaration is free. + +1. Take the declaration form (Annex I of the decree) from ANSSI's [forms + page](https://cyber.gouv.fr/reglementation/reglementation-identite-confiance-numerique/controles-reglementaires-cryptographie/controle-moyen-de-cryptologie/controle-reglementaire-cryptographie-formulaires/). + [Contrôle relatif à un moyen de + cryptologie](https://cyber.gouv.fr/reglementation/reglementation-identite-confiance-numerique/controles-reglementaires-cryptographie/controle-moyen-de-cryptologie/) + explains what to send. Describe the app's cryptography as in + [Encryption and export](#5-encryption-and-export). ANSSI asks the + supplier to request a "grand public" (consumer) classification in the + same declaration. +2. Send it to ANSSI as those pages describe, and keep the receipt. +3. A month after filing: **Pricing and Availability → App Availability → + add France**. + +## Later + +- **M3, push:** an APNs key (**Certificates, Identifiers & Profiles → Keys + → (+) → Apple Push Notification service**). Not before. +- **FTW Cloud:** update the privacy pages, App Privacy, `review-notes.txt` + and the description before it ships. +- **April 2027:** apps on the iOS 27.1 SDK or later also need iPhone Duo + screenshots. diff --git a/appleApp/scripts/demo-screenshots.sh b/appleApp/scripts/demo-screenshots.sh new file mode 100755 index 0000000..719a31e --- /dev/null +++ b/appleApp/scripts/demo-screenshots.sh @@ -0,0 +1,36 @@ +#!/usr/bin/env bash +# Photographs every screen of the demo in an iOS Simulator, for review. +# Boots the newest iPhone simulator, so run it where no one else is using +# one. Needs a Debug build in build/ios, as the Apple workflow makes: +# xcodebuild build -project FTW.xcodeproj -scheme FTW \ +# -destination 'generic/platform=iOS Simulator' -derivedDataPath build/ios +set -euo pipefail +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 +shoot now dark -FTWDemo YES -FTWTab now +shoot now-light light -FTWDemo YES -FTWTab now diff --git a/appleApp/scripts/store-screenshots.sh b/appleApp/scripts/store-screenshots.sh new file mode 100755 index 0000000..ee9967f --- /dev/null +++ b/appleApp/scripts/store-screenshots.sh @@ -0,0 +1,55 @@ +#!/usr/bin/env bash +# App Store screenshots: the demo on a 6.9" iPhone (1320 x 2868), in Swedish +# and English, ready for App Store Connect (RELEASING.md, step 10). +# +# Makes its own simulator and deletes it afterwards, so it leaves anyone +# else's alone. Needs a Debug build in build/ios, as for demo-screenshots.sh: +# xcodebuild build -project FTW.xcodeproj -scheme FTW \ +# -destination 'generic/platform=iOS Simulator' -derivedDataPath build/ios +# +# scripts/store-screenshots.sh [out] default build/store-screenshots +# FTW_STORE_DEVICE=iPhone-17-Pro another size (6.3", 1206 x 2622) +# FTW_STORE_LOOK=light light instead of dark +set -euo pipefail +cd "$(dirname "$0")/.." +app=build/ios/Build/Products/Debug-iphonesimulator/FTW.app +out=${1:-build/store-screenshots} +device=com.apple.CoreSimulator.SimDeviceType.${FTW_STORE_DEVICE:-iPhone-17-Pro-Max} +look=${FTW_STORE_LOOK:-dark} + +runtime=$(xcrun simctl list runtimes available --json | python3 -c ' +import json, sys +ios = [r for r in json.load(sys.stdin)["runtimes"] if r["platform"] == "iOS"] +print(max(ios, key=lambda r: [int(p) for p in r["version"].split(".")])["identifier"])') + +udid=$(xcrun simctl create "FTW store screenshots" "$device" "$runtime") +trap 'xcrun simctl shutdown "$udid" >/dev/null 2>&1 || true; xcrun simctl delete "$udid"' EXIT +xcrun simctl boot "$udid" +xcrun simctl bootstatus "$udid" -b >/dev/null +xcrun simctl ui "$udid" appearance "$look" +# The status bar Apple's own pictures show. +xcrun simctl status_bar "$udid" override --time 9:41 --dataNetwork wifi \ + --wifiMode active --wifiBars 3 --cellularMode active --cellularBars 4 \ + --batteryState charged --batteryLevel 100 +xcrun simctl install "$udid" "$app" + +# folder, language, name, then the app's launch arguments +shoot() { + local dir=$1 lang=$2 name=$3 + shift 3 + mkdir -p "$out/$dir" + xcrun simctl launch --terminate-running-process "$udid" energy.ftw.app \ + -AppleLanguages "($lang)" -AppleLocale "${dir/-/_}" "$@" >/dev/null + sleep 12 + xcrun simctl io "$udid" screenshot "$out/$dir/$name.png" >/dev/null 2>&1 + echo "$out/$dir/$name.png $(sips -g pixelWidth -g pixelHeight "$out/$dir/$name.png" | awk '/pixel/ {printf "%s ", $2}')" +} + +# -FTWTab and -FTWDemoCar only work in a Debug build with the car view. No +# pairing screen: in the simulator it shows a button a phone does not have. +for pair in sv-SE:sv en-US:en; do + dir=${pair%%:*} lang=${pair##*:} + shoot "$dir" "$lang" 1-now -FTWDemo YES + shoot "$dir" "$lang" 2-car -FTWDemo YES -FTWTab car + shoot "$dir" "$lang" 3-charging -FTWDemo YES -FTWTab car -FTWDemoCar charging +done diff --git a/appleApp/store/README.md b/appleApp/store/README.md new file mode 100644 index 0000000..bd33905 --- /dev/null +++ b/appleApp/store/README.md @@ -0,0 +1,27 @@ +# App Store texts + +What the App Store page and TestFlight say about FTW, one file per field, +pasted into App Store Connect by hand ([RELEASING.md](../RELEASING.md)). + +| File | App Store Connect field | Limit | +|---|---|---| +| `name.txt` | Name | 30 characters | +| `subtitle.txt` | Subtitle | 30 characters | +| `promotional-text.txt` | Promotional Text | 170 characters | +| `description.txt` | Description | 4000 characters | +| `keywords.txt` | Keywords, comma-separated, each three or more characters | 100 bytes | +| `release-notes.txt` | What's New, and What to Test in TestFlight | 4000 characters | +| `privacy-url.txt` | Privacy Policy URL | | +| `support-url.txt` | Support URL | | +| `marketing-url.txt` | Marketing URL | | +| `review-notes.txt` | App Review Information → Notes, one for all languages | 4000 bytes | + +`en-US` is the primary language, so countries without their own text show +it. `sv-SE` is Swedish. + +Claim only what the app on `main` does. Write plainly: short words, active +voice, no stock phrases, nothing left to cut. Then run: + +```bash +appleApp/store/check.sh +``` diff --git a/appleApp/store/check.sh b/appleApp/store/check.sh new file mode 100755 index 0000000..e1b0f10 --- /dev/null +++ b/appleApp/store/check.sh @@ -0,0 +1,44 @@ +#!/usr/bin/env bash +# Fails when a store text breaks one of Apple's limits for App Store Connect. +# Run it after editing anything in this folder. +set -euo pipefail +cd "$(dirname "$0")" +python3 - <<'PY' +import pathlib, sys + +# file: (limit, unit). Keywords and review notes count bytes; the rest count +# characters. +limits = { + "name.txt": (30, "chars"), + "subtitle.txt": (30, "chars"), + "promotional-text.txt": (170, "chars"), + "description.txt": (4000, "chars"), + "keywords.txt": (100, "bytes"), + "release-notes.txt": (4000, "chars"), + "review-notes.txt": (4000, "bytes"), +} +bad = 0 +for path in sorted(pathlib.Path(".").rglob("*.txt")): + text = path.read_text(encoding="utf-8").rstrip("\n") + if path.name.endswith("-url.txt"): + ok = text.startswith("https://") and "\n" not in text + print(f"{'ok ' if ok else 'BAD'} {path} {text}") + bad += not ok + continue + if path.name not in limits: + print(f"BAD {path} not a field this script knows") + bad += 1 + continue + limit, unit = limits[path.name] + size = len(text.encode()) if unit == "bytes" else len(text) + ok = 0 < size <= limit + if path.name == "keywords.txt": + words = text.split(",") + short = [w for w in words if len(w.strip()) <= 2] + if short: + ok = False + print(f" {path}: every keyword needs three or more characters: {short}") + print(f"{'ok ' if ok else 'BAD'} {path} {size}/{limit} {unit}") + bad += not ok +sys.exit(1 if bad else 0) +PY diff --git a/appleApp/store/en-US/description.txt b/appleApp/store/en-US/description.txt new file mode 100644 index 0000000..7453255 --- /dev/null +++ b/appleApp/store/en-US/description.txt @@ -0,0 +1,30 @@ +See your home's energy on your iPhone, straight from the FTW box at home. Steer the car's charging and check electricity prices. + +FTW is open-source home energy software from Sourceful. It runs on a box in your home, reads your meters and devices, plans the battery and the car, and controls them on site. This app shows you that box. You say what you want; the box decides how. + +NOW +• Is FTW in control? One line tells you, and why not when it isn't. +• Live power between solar, battery, home, grid and car. +• How much of today's power your home made itself. +• The electricity price now, the cheapest two hours and the hours ahead. +• Every reading shows its age. When the link drops, the app says so and reconnects on its own. + +YOUR CAR +• The car's level, and where it came from: the car, you or FTW's estimate. +• Correct it with 20, 40, 60 or 80 %, or by 5 % up or down. +• Set a goal: how full and by when, once or on chosen days. +• See whether the plan will reach it. +• Charge now, Pause or Back to the plan. Each command shows when it was sent, when the box got it and when it was done. + +PRICE WIDGETS +• The electricity price on your Home Screen, Lock Screen and in StandBy. +• Widgets show what the app last read from your box, and how old it is. + +PRIVACY +• No account and no password. Scan the pairing code your box shows and confirm with a passkey. +• Your data stays on your box and your phone. The app reaches the box through Sourceful's relay, which carries encrypted traffic it cannot read. +• No ads, no analytics, no tracking. + +WHAT YOU NEED +• An FTW box at home with the FTW app turned on in its settings. FTW runs on a Raspberry Pi or another Linux computer. Learn more at ftw.energy. +• No box? Try the demo, a simulated home that runs inside the app. diff --git a/appleApp/store/en-US/keywords.txt b/appleApp/store/en-US/keywords.txt new file mode 100644 index 0000000..7db399f --- /dev/null +++ b/appleApp/store/en-US/keywords.txt @@ -0,0 +1 @@ +solar,battery,charging,electricity,price,spot price,home energy,HEMS,grid,kWh,widget,smart home diff --git a/appleApp/store/en-US/marketing-url.txt b/appleApp/store/en-US/marketing-url.txt new file mode 100644 index 0000000..9a3bca2 --- /dev/null +++ b/appleApp/store/en-US/marketing-url.txt @@ -0,0 +1 @@ +https://ftw.sourceful.energy/ diff --git a/appleApp/store/en-US/name.txt b/appleApp/store/en-US/name.txt new file mode 100644 index 0000000..fbbe77b --- /dev/null +++ b/appleApp/store/en-US/name.txt @@ -0,0 +1 @@ +FTW diff --git a/appleApp/store/en-US/privacy-url.txt b/appleApp/store/en-US/privacy-url.txt new file mode 100644 index 0000000..4e8ffa6 --- /dev/null +++ b/appleApp/store/en-US/privacy-url.txt @@ -0,0 +1 @@ +https://ftw.sourceful.energy/privacy diff --git a/appleApp/store/en-US/promotional-text.txt b/appleApp/store/en-US/promotional-text.txt new file mode 100644 index 0000000..3659018 --- /dev/null +++ b/appleApp/store/en-US/promotional-text.txt @@ -0,0 +1 @@ +See what your FTW box sees, with the age of every reading. Set the car's level and goal, or charge now. Electricity prices on your Home Screen and Lock Screen. diff --git a/appleApp/store/en-US/release-notes.txt b/appleApp/store/en-US/release-notes.txt new file mode 100644 index 0000000..ba26095 --- /dev/null +++ b/appleApp/store/en-US/release-notes.txt @@ -0,0 +1 @@ +The first FTW app for iPhone. Pair with your box, see your home's energy now, steer the car's charging and put electricity prices on your Home Screen and Lock Screen. diff --git a/appleApp/store/en-US/subtitle.txt b/appleApp/store/en-US/subtitle.txt new file mode 100644 index 0000000..f3cee54 --- /dev/null +++ b/appleApp/store/en-US/subtitle.txt @@ -0,0 +1 @@ +Your home energy, on the phone diff --git a/appleApp/store/en-US/support-url.txt b/appleApp/store/en-US/support-url.txt new file mode 100644 index 0000000..56d9215 --- /dev/null +++ b/appleApp/store/en-US/support-url.txt @@ -0,0 +1 @@ +https://ftw.sourceful.energy/support diff --git a/appleApp/store/review-notes.txt b/appleApp/store/review-notes.txt new file mode 100644 index 0000000..73fcbb4 --- /dev/null +++ b/appleApp/store/review-notes.txt @@ -0,0 +1,22 @@ +FTW is the phone app for FTW, open-source home energy software that runs on a box in the user's home (a Raspberry Pi or another Linux computer). The app talks only to that box, through an end-to-end encrypted relay. There is no account and nothing to sign in to. You do not have a box, so the app has a built-in demo for you. + +THE DEMO +1. Open the app. On the first screen, tap "Try the live demo". +2. The demo runs a simulated FTW box inside the app. It needs no network and no passkey, and it saves nothing. +3. Now shows "Is FTW in control?", live power between solar, battery, home, grid and car, and today's electricity prices. +4. Tap the car to open the car view. Try the level buttons (20/40/60/80 % and ±5), set a goal, then tap Charge now, Pause and Back to the plan. The simulated box answers each command, and the app shows when it was sent, received and done. +5. "Exit demo" at the top returns to the first screen. + +WITH A REAL BOX (needs the hardware) +The owner opens the box's local dashboard, chooses Settings → FTW app → Show pairing code and scans the QR code with this app or the iPhone camera (links to https://app.ftw.energy/p). The app then creates a passkey for app.ftw.energy and connects through wss://relay.ftw.energy. + +WIDGETS +The price widgets show prices the app saved from a real box. The demo saves nothing, so in review a widget says "Open FTW to see prices". + +OTHER +- Privacy policy: "Privacy" at the foot of Now and of the first screen, and in About. +- No account to delete. "Remove this phone" at the foot of Now unpairs the phone. +- Camera: used only to read the pairing QR code. +- No sign-in, no in-app purchases, no ads, no analytics, no tracking. +- Encryption: Noise protocol built on Apple's CryptoKit only. +- The demo stands in for the hardware, not for a login: the app has no login. diff --git a/appleApp/store/sv-SE/description.txt b/appleApp/store/sv-SE/description.txt new file mode 100644 index 0000000..5e11860 --- /dev/null +++ b/appleApp/store/sv-SE/description.txt @@ -0,0 +1,30 @@ +Se hemmets energi i din iPhone, direkt från FTW-boxen hemma. Styr bilens laddning och se elpriset. + +FTW är hemenergi med öppen källkod från Sourceful. Det körs på en box hemma hos dig, läser av mätare och enheter, planerar batteriet och bilen och styr dem på plats. Appen visar dig boxen. Du säger vad du vill, och boxen bestämmer hur. + +NU +• Styr FTW? En rad svarar, och säger varför när svaret är nej. +• Effekten just nu mellan sol, batteri, hem, elnät och bil. +• Hur stor del av dagens el hemmet gjorde själv. +• Elpriset nu, de två billigaste timmarna och timmarna framåt. +• Varje värde visar sin ålder. Bryts kontakten säger appen det och ansluter igen av sig själv. + +BILEN +• Bilens nivå, och var den kommer ifrån: bilen, du eller FTW:s uppskattning. +• Rätta den med 20, 40, 60 eller 80 %, eller 5 % upp eller ner. +• Sätt ett mål: hur full och till när, en gång eller vissa dagar. +• Se om planen hinner. +• Ladda nu, Pausa eller Tillbaka till planen. Varje kommando visar när det skickades, när boxen fick det och när det var utfört. + +PRISWIDGETAR +• Elpriset på hemskärmen, låsskärmen och i Vänteläge. +• Widgetarna visar det appen senast läste från boxen, och hur gammalt det är. + +INTEGRITET +• Inget konto och inget lösenord. Skanna parkopplingskoden som boxen visar och bekräfta med en lösenordsnyckel. +• Din data stannar i boxen och i telefonen. Appen når boxen genom Sourcefuls relä, som bär krypterad trafik det inte kan läsa. +• Ingen reklam, ingen analys, ingen spårning. + +DET HÄR BEHÖVER DU +• En FTW-box hemma med FTW-appen påslagen i boxens inställningar. FTW körs på en Raspberry Pi eller en annan Linuxdator. Läs mer på ftw.energy. +• Ingen box? Prova demon, ett simulerat hem som körs i appen. diff --git a/appleApp/store/sv-SE/keywords.txt b/appleApp/store/sv-SE/keywords.txt new file mode 100644 index 0000000..b163969 --- /dev/null +++ b/appleApp/store/sv-SE/keywords.txt @@ -0,0 +1 @@ +solceller,batteri,elbil,laddning,elpris,spotpris,energi,hemenergi,elnät,kWh,widget,smarta hem diff --git a/appleApp/store/sv-SE/marketing-url.txt b/appleApp/store/sv-SE/marketing-url.txt new file mode 100644 index 0000000..9a3bca2 --- /dev/null +++ b/appleApp/store/sv-SE/marketing-url.txt @@ -0,0 +1 @@ +https://ftw.sourceful.energy/ diff --git a/appleApp/store/sv-SE/name.txt b/appleApp/store/sv-SE/name.txt new file mode 100644 index 0000000..fbbe77b --- /dev/null +++ b/appleApp/store/sv-SE/name.txt @@ -0,0 +1 @@ +FTW diff --git a/appleApp/store/sv-SE/privacy-url.txt b/appleApp/store/sv-SE/privacy-url.txt new file mode 100644 index 0000000..0867b0e --- /dev/null +++ b/appleApp/store/sv-SE/privacy-url.txt @@ -0,0 +1 @@ +https://ftw.sourceful.energy/sv/privacy diff --git a/appleApp/store/sv-SE/promotional-text.txt b/appleApp/store/sv-SE/promotional-text.txt new file mode 100644 index 0000000..9d31321 --- /dev/null +++ b/appleApp/store/sv-SE/promotional-text.txt @@ -0,0 +1 @@ +Se det din FTW-box ser, med åldern på varje värde. Ange bilens nivå och mål, eller ladda nu. Elpriset på hemskärmen och låsskärmen. diff --git a/appleApp/store/sv-SE/release-notes.txt b/appleApp/store/sv-SE/release-notes.txt new file mode 100644 index 0000000..cb6ef2e --- /dev/null +++ b/appleApp/store/sv-SE/release-notes.txt @@ -0,0 +1 @@ +Den första FTW-appen för iPhone. Parkoppla med din box, se hemmets energi just nu, styr bilens laddning och lägg elpriset på hemskärmen och låsskärmen. diff --git a/appleApp/store/sv-SE/subtitle.txt b/appleApp/store/sv-SE/subtitle.txt new file mode 100644 index 0000000..495aeda --- /dev/null +++ b/appleApp/store/sv-SE/subtitle.txt @@ -0,0 +1 @@ +Hemmets energi i telefonen diff --git a/appleApp/store/sv-SE/support-url.txt b/appleApp/store/sv-SE/support-url.txt new file mode 100644 index 0000000..ba6abf7 --- /dev/null +++ b/appleApp/store/sv-SE/support-url.txt @@ -0,0 +1 @@ +https://ftw.sourceful.energy/sv/support 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" } -}