Procházet zdrojové kódy

Add therapy settings upload to Tidepool (#975)

* Add optional therapy settings upload to Tidepool

Adds an "Include Therapy Settings" toggle to the Tidepool settings
screen, allowing users to opt-in to uploading their therapy
configuration (basal schedules, carb ratios, insulin sensitivity factors,
BG targets, and override presets) alongside their diabetes data.
Tidepool represents these as "Pump Settings" in their data model.

Architecture decisions:

- The toggle state is stored in TrioSettings (persisted via FileStorage)
  rather than in the TidepoolService submodule. This keeps all Trio
  settings in one place and avoids modifying upstream dependencies.

- TrioSettingsAdapter converts Trio's JSON-based settings format to
  LoopKit's StoredSettings format, which TidepoolService then converts
  to the Tidepool API format. This two-step conversion maintains clean
  separation between Trio's data model and the upload mechanism.

- The toggle is disabled when Tidepool is not connected, preventing
  users from enabling a feature that would have no effect.

- TidepoolManager listens for therapy setting changes via the existing
  observer pattern (SettingsObserver, BasalProfileObserver, etc.) and
  triggers debounced uploads when the toggle is enabled.

- Upload requests are debounced (1.5s) to prevent redundant uploads
  when multiple settings observers fire in rapid succession.

- syncIdentifier uses a content-based SHA-256 hash of therapy values
  instead of a random UUID, enabling Tidepool server-side deduplication.

- Basal schedule conversion uses the numeric minutes field directly
  instead of parsing the start time string, avoiding format mismatches.

Note: CarbRatios and InsulinSensitivities observers are not yet wired
up to trigger uploads - this will be addressed in a follow-up commit.

* Add missing observers for CarbRatios and InsulinSensitivities

Therapy setting changes for carb ratios and insulin sensitivity factors
were not triggering Tidepool uploads because the observer pattern was
incomplete. BGTargets and BasalProfile had observers, but CarbRatios
and InsulinSensitivities did not.

Changes:
- Add CarbRatiosObserver protocol to CarbRatios.swift
- Add InsulinSensitivitiesObserver protocol to InsulinSensitivities.swift
- Broadcast changes from CarbRatioEditorStateModel.save()
- Broadcast changes from ISFEditorStateModel.save()
- Register TidepoolManager for both new observers
- Implement observer methods to trigger uploadSettings()

Now all four therapy settings (BG targets, basal profile, carb ratios,
and insulin sensitivities) properly trigger Tidepool uploads when the
"Include Therapy Settings" toggle is enabled.

* Add active override and temp target upload to Tidepool

Converts currently active Profile Overrides and Temp Targets to
Tidepool's scheduleOverride format for upload. Profile Overrides
include insulin needs scaling (percentage), while Temp Targets
only modify the glucose target.

Note: Override presets and active overrides are included in the
upload payload, but Tidepool's web UI does not currently display
them. The data is present in the API response and may be surfaced
in the future once Trio is registered as a recognized app with
Tidepool. The UI description has been updated to not mention
overrides to avoid user confusion.

Also adds TempTargetsObserver registration to trigger uploads when
temp targets change, and documents the hostIdentifier mapping issue.

* Remove therapy settings toggle — always upload when connected

Therapy settings (basal schedules, carb ratios, insulin sensitivities,
and BG targets) are now uploaded automatically whenever connected to
Tidepool, removing the opt-in "Include Therapy Settings" toggle per
PR review feedback.

* Remove overrides and temp targets from Tidepool upload

Overrides are buggy and temp targets shouldn't be mixed with them.
Remove all override/temp target conversion code, Core Data queries,
related observers, and corresponding tests.

* Fix suspend threshold glucose unit handling

threshold_setting in Preferences is always stored in mg/dL, but was
being labeled with the user's display unit (bgUnit). If the user
displays in mmol/L, a value like 70 (mg/dL) would be incorrectly
labeled as 70 mmol/L.

Always create the GlucoseThreshold in milligramsPerDeciliter since
that matches the stored value. TidepoolServiceKit converts all glucose
values to mg/dL before uploading (via convertTo(unit:)), and the
Tidepool API always stores data in mg/dL. The mmol/L option in the
Tidepool web UI is a display preference only.

* Fix insulin model to use user's DIA, peak time, and insulin type

- Read DIA from pumpSettings.insulinActionCurve (5-10h) instead of
  hard-coded 6h
- Use ExponentialInsulinModelPreset defaults for peak activity (75min
  for rapidActing, 55min for fiasp/lyumjev) instead of hard-coded
  2.5/3 hours which were incorrect
- Respect useCustomPeakTime toggle — when enabled, use the user's
  insulinPeakTime setting (35-120min)
- Distinguish Fiasp vs Lyumjev via pumpManager's configured insulin
  type instead of mapping both to .fiasp

Note: TidepoolServiceKit currently maps .lyumjev model type to .other
in its Tidepool datum conversion. When TidepoolServiceKit adds native
.lyumjev support, the correct model type will flow through
automatically.

* Move settings conversion into TidepoolManager and switch to direct uploads

Move all therapy settings conversion logic from the standalone
TrioSettingsAdapter into a BaseTidepoolManager extension, eliminating
the separate injectable class.

Replace observer-based upload pattern (CarbRatios, InsulinSensitivities,
BasalProfile, BGTargets, PumpSettings observers) with direct
Task.detached upload calls in each state model's save(), matching the
existing Nightscout upload pattern. Keep SettingsObserver and
PreferencesObserver for closedLoop/units/algorithm preference changes.

* Rewrite Tidepool therapy settings tests in Swift Testing framework

Replace XCTest with Swift Testing to match Trio's test conventions:
- XCTestCase classes → @Suite structs
- func testXxx() → @Test("description") func xxx()
- XCTAssert* → #expect()

* Update Tidepool hint text with reviewer's suggested wording

Clarify what data Trio uploads: glucose, carb entries, insulin (bolus
and basal), pump settings, and therapy settings. List therapy settings
explicitly: basal schedules, carb ratios, insulin sensitivities, and
glucose targets.

* Update hostIdentifier to org.nightscout.Trio

* Update BuildDetails.plist to use Trio-specific Tidepool OAuth2 endpoint

* Fix TidepoolServiceRedirectURL typo

* Linting fix

* Remove whitespace

---------

Co-authored-by: Deniz Cengiz <48965855+dnzxy@users.noreply.github.com>
Bryan Lalezarian před 5 dny
rodič
revize
1c8c5d2e87

+ 3 - 1
BuildDetails.plist

@@ -3,6 +3,8 @@
 <plist version="1.0">
 <dict>
 	<key>TidepoolServiceClientId</key>
-	<string>diy-loop</string>
+	<string>nightscout-trio</string>
+	<key>TidepoolServiceRedirectURL</key>
+	<string>org.nightscout.trio.tidepoolkit.auth://redirect</string>
 </dict>
 </plist>

+ 4 - 0
Trio.xcodeproj/project.pbxproj

@@ -418,6 +418,7 @@
 		BD8FC0622D6619E600B95AED /* OverrideStorageTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = BD8FC0612D6619E600B95AED /* OverrideStorageTests.swift */; };
 		BD8FC0642D6619EF00B95AED /* TempTargetStorageTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = BD8FC0632D6619EF00B95AED /* TempTargetStorageTests.swift */; };
 		BD8FC0662D661A0000B95AED /* GlucoseStorageTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = BD8FC0652D661A0000B95AED /* GlucoseStorageTests.swift */; };
+		BD8FC0712D661B0000B95AED /* TidepoolTherapySettingsTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = BD8FC0702D661B0000B95AED /* TidepoolTherapySettingsTests.swift */; };
 		BDA25EE42D260CD500035F34 /* AppleWatchManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = BDA25EE32D260CCF00035F34 /* AppleWatchManager.swift */; };
 		BDA25EE62D260D5E00035F34 /* WatchState.swift in Sources */ = {isa = PBXBuildFile; fileRef = BDA25EE52D260D5800035F34 /* WatchState.swift */; };
 		BDA25EFD2D261C0000035F34 /* WatchState.swift in Sources */ = {isa = PBXBuildFile; fileRef = BDA25EFC2D261BF200035F34 /* WatchState.swift */; };
@@ -1268,6 +1269,7 @@
 		BD8FC0612D6619E600B95AED /* OverrideStorageTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OverrideStorageTests.swift; sourceTree = "<group>"; };
 		BD8FC0632D6619EF00B95AED /* TempTargetStorageTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TempTargetStorageTests.swift; sourceTree = "<group>"; };
 		BD8FC0652D661A0000B95AED /* GlucoseStorageTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GlucoseStorageTests.swift; sourceTree = "<group>"; };
+		BD8FC0702D661B0000B95AED /* TidepoolTherapySettingsTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TidepoolTherapySettingsTests.swift; sourceTree = "<group>"; };
 		BDA25EE32D260CCF00035F34 /* AppleWatchManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppleWatchManager.swift; sourceTree = "<group>"; };
 		BDA25EE52D260D5800035F34 /* WatchState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WatchState.swift; sourceTree = "<group>"; };
 		BDA25EFC2D261BF200035F34 /* WatchState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WatchState.swift; sourceTree = "<group>"; };
@@ -2696,6 +2698,7 @@
 				3B997DCE2DC00A3A006B6BB2 /* JSONImporterTests.swift */,
 				CE1F6DD82BADF4620064EB8D /* PluginManagerTests.swift */,
 				BD8FC0532D66186000B95AED /* TestError.swift */,
+				BD8FC0702D661B0000B95AED /* TidepoolTherapySettingsTests.swift */,
 			);
 			path = TrioTests;
 			sourceTree = "<group>";
@@ -4880,6 +4883,7 @@
 				38FCF3F925E902C20078B0D1 /* FileStorageTests.swift in Sources */,
 				BD8FC0602D6619DB00B95AED /* CarbsStorageTests.swift in Sources */,
 				BD8FC05E2D6618CE00B95AED /* BolusCalculatorTests.swift in Sources */,
+				BD8FC0712D661B0000B95AED /* TidepoolTherapySettingsTests.swift in Sources */,
 			);
 			runOnlyForDeploymentPostprocessing = 0;
 		};

+ 5 - 1
Trio/Sources/Localizations/Main/Localizable.xcstrings

@@ -264168,6 +264168,9 @@
         }
       }
     },
+    "Use your Tidepool credentials to log in. If you don't have a Tidepool account, you can sign up on the login page.\n\nWhen connected, Trio uploads your glucose, carb entries, insulin (bolus and basal), pump settings, and therapy settings to Tidepool.\n\nTherapy settings include basal schedules, carb ratios, insulin sensitivities, and glucose targets." : {
+
+    },
     "User Interface" : {
       "localizations" : {
         "bg" : {
@@ -269964,6 +269967,7 @@
       }
     },
     "When connected, uploading of carbs, bolus, basal and glucose from Trio to your Tidepool account is enabled.\n\nUse your Tidepool credentials to login. If you dont already have a Tidepool account, you can sign up for one on the login page." : {
+      "extractionState" : "stale",
       "localizations" : {
         "bg" : {
           "stringUnit" : {
@@ -277366,4 +277370,4 @@
     }
   },
   "version" : "1.1"
-}
+}

+ 4 - 0
Trio/Sources/Models/CarbRatios.swift

@@ -1,5 +1,9 @@
 import Foundation
 
+protocol CarbRatiosObserver {
+    func carbRatiosDidChange(_ carbRatios: CarbRatios)
+}
+
 struct CarbRatios: JSON {
     let units: CarbUnit
     let schedule: [CarbRatioEntry]

+ 4 - 0
Trio/Sources/Models/InsulinSensitivities.swift

@@ -1,5 +1,9 @@
 import Foundation
 
+protocol InsulinSensitivitiesObserver {
+    func insulinSensitivitiesDidChange(_ sensitivities: InsulinSensitivities)
+}
+
 struct InsulinSensitivities: JSON {
     var units: GlucoseUnits
     var userPreferredUnits: GlucoseUnits

+ 5 - 0
Trio/Sources/Modules/AlgorithmAdvancedSettings/AlgorithmAdvancedSettingsStateModel.swift

@@ -7,6 +7,7 @@ extension AlgorithmAdvancedSettings {
         @Injected() var settings: SettingsManager!
         @Injected() var storage: FileStorage!
         @Injected() var nightscout: NightscoutManager!
+        @Injected() private var tidepoolManager: TidepoolManager!
 
         var units: GlucoseUnits = .mgdL
 
@@ -78,6 +79,10 @@ extension AlgorithmAdvancedSettings {
                                 )
                             }
                         }
+
+                        Task.detached(priority: .low) {
+                            await self.tidepoolManager.uploadSettings()
+                        }
                     } receiveValue: {}
                     .store(in: &lifetime)
             }

+ 5 - 0
Trio/Sources/Modules/BasalProfileEditor/BasalProfileEditorStateModel.swift

@@ -4,6 +4,7 @@ import SwiftUI
 extension BasalProfileEditor {
     @Observable final class StateModel: BaseStateModel<Provider> {
         @ObservationIgnored @Injected() private var nightscout: NightscoutManager!
+        @ObservationIgnored @Injected() private var tidepoolManager: TidepoolManager!
         @ObservationIgnored @Injected() private var broadcaster: Broadcaster!
 
         var syncInProgress: Bool = false
@@ -127,6 +128,10 @@ extension BasalProfileEditor {
                                 debug(.default, "Failed to upload basal rates to Nightscout: \(error)")
                             }
                         }
+
+                        Task.detached(priority: .low) {
+                            await self.tidepoolManager.uploadSettings()
+                        }
                     case .failure:
                         // Handle the error, show error message
                         self.showAlert = true

+ 13 - 0
Trio/Sources/Modules/CarbRatioEditor/CarbRatioEditorStateModel.swift

@@ -3,6 +3,8 @@ import SwiftUI
 extension CarbRatioEditor {
     final class StateModel: BaseStateModel<Provider> {
         @Injected() private var nightscout: NightscoutManager!
+        @Injected() private var tidepoolManager: TidepoolManager!
+        @Injected() private var broadcaster: Broadcaster!
         @Published var items: [Item] = []
         @Published var initialItems: [Item] = []
         @Published var therapyItems: [TherapySettingItem] = []
@@ -89,6 +91,13 @@ extension CarbRatioEditor {
             let profile = CarbRatios(units: .grams, schedule: schedule)
             provider.saveProfile(profile)
             initialItems = items.map { Item(rateIndex: $0.rateIndex, timeIndex: $0.timeIndex) }
+
+            DispatchQueue.main.async {
+                self.broadcaster.notify(CarbRatiosObserver.self, on: .main) {
+                    $0.carbRatiosDidChange(profile)
+                }
+            }
+
             Task.detached(priority: .low) {
                 do {
                     debug(.nightscout, "Attempting to upload CRs to Nightscout")
@@ -97,6 +106,10 @@ extension CarbRatioEditor {
                     debug(.default, "Failed to upload CRs to Nightscout: \(error)")
                 }
             }
+
+            Task.detached(priority: .low) {
+                await self.tidepoolManager.uploadSettings()
+            }
         }
 
         func validate() {

+ 5 - 0
Trio/Sources/Modules/GeneralSettings/UnitsLimitsSettingsStateModel.swift

@@ -5,6 +5,7 @@ extension UnitsLimitsSettings {
     final class StateModel: BaseStateModel<Provider> {
         @Injected() var settings: SettingsManager!
         @Injected() var storage: FileStorage!
+        @Injected() private var tidepoolManager: TidepoolManager!
 
         @Published var units: GlucoseUnits = .mgdL
         @Published var unitsIndex = 0 // index 0 is mg/dl
@@ -56,6 +57,10 @@ extension UnitsLimitsSettings {
                         let settings = self.provider.settings()
                         self.maxBasal = settings.maxBasal
                         self.maxBolus = settings.maxBolus
+
+                        Task.detached(priority: .low) {
+                            await self.tidepoolManager.uploadSettings()
+                        }
                     } receiveValue: {}
                     .store(in: &lifetime)
             }

+ 12 - 0
Trio/Sources/Modules/ISFEditor/ISFEditorStateModel.swift

@@ -16,6 +16,8 @@ extension ISFEditor {
     @Observable final class StateModel: BaseStateModel<Provider> {
         @ObservationIgnored @Injected() var determinationStorage: DeterminationStorage!
         @ObservationIgnored @Injected() private var nightscout: NightscoutManager!
+        @ObservationIgnored @Injected() private var tidepoolManager: TidepoolManager!
+        @ObservationIgnored @Injected() private var broadcaster: Broadcaster!
 
         var items: [Item] = []
         var initialItems: [Item] = []
@@ -118,6 +120,12 @@ extension ISFEditor {
             provider.saveProfile(profile)
             initialItems = items.map { Item(rateIndex: $0.rateIndex, timeIndex: $0.timeIndex) }
 
+            DispatchQueue.main.async {
+                self.broadcaster.notify(InsulinSensitivitiesObserver.self, on: .main) {
+                    $0.insulinSensitivitiesDidChange(profile)
+                }
+            }
+
             Task.detached(priority: .low) {
                 do {
                     debug(.nightscout, "Attempting to upload ISF to Nightscout")
@@ -129,6 +137,10 @@ extension ISFEditor {
                     )
                 }
             }
+
+            Task.detached(priority: .low) {
+                await self.tidepoolManager.uploadSettings()
+            }
         }
 
         func validate() {

+ 0 - 1
Trio/Sources/Modules/Settings/SettingsStateModel.swift

@@ -31,7 +31,6 @@ extension Settings {
 
             subscribeSetting(\.debugOptions, on: $debugOptions) { debugOptions = $0 }
             subscribeSetting(\.closedLoop, on: $closedLoop) { closedLoop = $0 }
-
             broadcaster.register(SettingsObserver.self, observer: self)
 
             buildNumber = Bundle.main.infoDictionary?["CFBundleVersion"] as? String ?? "Unknown"

+ 1 - 1
Trio/Sources/Modules/Settings/View/TidepoolStartView.swift

@@ -99,7 +99,7 @@ struct TidepoolStartView: BaseView {
                 shouldDisplayHint: $shouldDisplayHint,
                 hintLabel: "Connect to Tidepool",
                 hintText: Text(
-                    "When connected, uploading of carbs, bolus, basal and glucose from Trio to your Tidepool account is enabled.\n\nUse your Tidepool credentials to login. If you dont already have a Tidepool account, you can sign up for one on the login page."
+                    "Use your Tidepool credentials to log in. If you don't have a Tidepool account, you can sign up on the login page.\n\nWhen connected, Trio uploads your glucose, carb entries, insulin (bolus and basal), pump settings, and therapy settings to Tidepool.\n\nTherapy settings include basal schedules, carb ratios, insulin sensitivities, and glucose targets."
                 ),
                 sheetTitle: String(localized: "Help", comment: "Help sheet title")
             )

+ 5 - 0
Trio/Sources/Modules/TargetsEditor/TargetsEditorStateModel.swift

@@ -3,6 +3,7 @@ import SwiftUI
 extension TargetsEditor {
     final class StateModel: BaseStateModel<Provider> {
         @Injected() private var nightscout: NightscoutManager!
+        @Injected() private var tidepoolManager: TidepoolManager!
         @Injected() private var broadcaster: Broadcaster!
 
         @Published var items: [Item] = []
@@ -113,6 +114,10 @@ extension TargetsEditor {
                     )
                 }
             }
+
+            Task.detached(priority: .low) {
+                await self.tidepoolManager.uploadSettings()
+            }
         }
 
         func validate() {

+ 304 - 2
Trio/Sources/Services/Network/TidepoolManager.swift

@@ -1,11 +1,13 @@
 import Combine
 import CoreData
+import CryptoKit
 import Foundation
 import HealthKit
 import LoopKit
 import LoopKitUI
 import Swinject
 import TidepoolServiceKit
+import UIKit
 
 protocol TidepoolManager {
     func addTidepoolService(service: Service)
@@ -16,6 +18,7 @@ protocol TidepoolManager {
     func uploadInsulin() async
     func deleteInsulin(withSyncId id: String, amount: Decimal, at: Date)
     func uploadGlucose() async
+    func uploadSettings() async
     func forceTidepoolDataUpload()
 }
 
@@ -27,8 +30,25 @@ final class BaseTidepoolManager: TidepoolManager, Injectable {
     @Injected() private var storage: FileStorage!
     @Injected() private var pumpHistoryStorage: PumpHistoryStorage!
     @Injected() private var apsManager: APSManager!
+    @Injected() private var settingsManager: SettingsManager!
+
+    // Lazy access to avoid circular dependency (TidepoolManager ↔ FetchGlucoseManager)
+    private var resolver: Resolver?
 
     private let processQueue = DispatchQueue(label: "BaseNetworkManager.processQueue")
+
+    /// Pending debounce work item for settings upload; cancelled and rescheduled
+    /// each time an observer fires, so rapid changes coalesce into one upload.
+    /// - Important: Only access from `processQueue` to ensure thread safety.
+    private var pendingSettingsUpload: DispatchWorkItem?
+
+    /// Delay before a debounced settings upload fires.
+    private static let settingsUploadDebounceDelay: TimeInterval = 1.5
+
+    /// Last-seen therapy-relevant TrioSettings values.
+    /// Used to filter `settingsDidChange` so UI-only changes don't trigger uploads.
+    private var lastClosedLoop: Bool?
+    private var lastUnits: GlucoseUnits?
     private var tidepoolService: RemoteDataService? {
         didSet {
             if let tidepoolService = tidepoolService {
@@ -49,6 +69,7 @@ final class BaseTidepoolManager: TidepoolManager, Injectable {
     @PersistedProperty(key: "TidepoolState") var rawTidepoolManager: Service.RawValue?
 
     init(resolver: Resolver) {
+        self.resolver = resolver
         injectServices(resolver)
         loadTidepoolManager()
 
@@ -133,6 +154,10 @@ final class BaseTidepoolManager: TidepoolManager, Injectable {
                 await self.uploadGlucose()
             }
         }.store(in: &subscriptions)
+
+        // Register for settings that aren't saved from a single editor screen
+        broadcaster.register(SettingsObserver.self, observer: self)
+        broadcaster.register(PreferencesObserver.self, observer: self)
     }
 
     func sourceInfo() -> [String: Any]? {
@@ -145,14 +170,14 @@ final class BaseTidepoolManager: TidepoolManager, Injectable {
             await uploadInsulin()
             await uploadCarbs()
             await uploadGlucose()
+            await uploadSettings()
         }
     }
 }
 
 extension BaseTidepoolManager: ServiceDelegate {
     var hostIdentifier: String {
-        // TODO: shouldn't this rather be `org.nightscout.Trio` ?
-        "com.loopkit.Loop" // To check
+        "org.nightscout.Trio"
     }
 
     var hostVersion: String {
@@ -648,6 +673,79 @@ extension BaseTidepoolManager {
     }
 }
 
+/// Settings Upload Functionality
+extension BaseTidepoolManager {
+    /// Debounces settings upload requests.
+    /// Cancels any pending upload and schedules a new one after the debounce delay.
+    /// This prevents redundant uploads when multiple settings observers fire in rapid succession.
+    /// All access to `pendingSettingsUpload` is serialized on `processQueue`.
+    private func scheduleSettingsUpload() {
+        processQueue.async { [weak self] in
+            guard let self = self else { return }
+            self.pendingSettingsUpload?.cancel()
+            let workItem = DispatchWorkItem { [weak self] in
+                guard let self = self else { return }
+                Task {
+                    await self.uploadSettings()
+                }
+            }
+            self.pendingSettingsUpload = workItem
+            self.processQueue.asyncAfter(
+                deadline: .now() + Self.settingsUploadDebounceDelay,
+                execute: workItem
+            )
+        }
+    }
+
+    func uploadSettings() async {
+        guard let tidepoolService = self.tidepoolService as? TidepoolService else {
+            return
+        }
+
+        // Get CGM device info (lazily resolved to avoid circular dependency)
+        let fetchGlucoseManager = resolver?.resolve(FetchGlucoseManager.self)
+        let cgmDevice = fetchGlucoseManager?.cgmManager?.cgmManagerStatus.device
+
+        guard let settings = createStoredSettings(cgmDevice: cgmDevice) else {
+            return
+        }
+
+        processQueue.async {
+            tidepoolService.uploadSettingsData([settings]) { result in
+                switch result {
+                case .success:
+                    debug(.service, "Settings uploaded to Tidepool (syncId: \(settings.syncIdentifier))")
+                case let .failure(error):
+                    debug(.service, "Failed to upload settings to Tidepool: \(error)")
+                }
+            }
+        }
+    }
+}
+
+// MARK: - Settings Change Observers
+
+extension BaseTidepoolManager: SettingsObserver {
+    func settingsDidChange(_ settings: TrioSettings) {
+        // Only trigger upload when therapy-relevant properties change.
+        // TrioSettings has ~56 properties, most are UI-only (badges, colors, etc.).
+        let closedLoopChanged = lastClosedLoop != settings.closedLoop
+        let unitsChanged = lastUnits != settings.units
+
+        lastClosedLoop = settings.closedLoop
+        lastUnits = settings.units
+
+        guard closedLoopChanged || unitsChanged else { return }
+        scheduleSettingsUpload()
+    }
+}
+
+extension BaseTidepoolManager: PreferencesObserver {
+    func preferencesDidChange(_: Preferences) {
+        scheduleSettingsUpload()
+    }
+}
+
 extension BaseTidepoolManager: StatefulPluggableDelegate {
     func pluginDidUpdateState(_: LoopKit.StatefulPluggable) {}
 
@@ -656,6 +754,210 @@ extension BaseTidepoolManager: StatefulPluggableDelegate {
     }
 }
 
+// MARK: - Settings Conversion
+
+extension BaseTidepoolManager {
+    /// Creates a StoredSettings object from current Trio settings
+    /// - Parameter cgmDevice: Optional CGM device info (pass from FetchGlucoseManager to avoid circular dependency)
+    func createStoredSettings(cgmDevice: HKDevice? = nil) -> StoredSettings? {
+        guard let basalProfile: [BasalProfileEntry] = storage
+            .retrieve(OpenAPS.Settings.basalProfile, as: [BasalProfileEntry].self),
+            let carbRatios: CarbRatios = storage.retrieve(OpenAPS.Settings.carbRatios, as: CarbRatios.self),
+            let insulinSensitivities: InsulinSensitivities = storage.retrieve(
+                OpenAPS.Settings.insulinSensitivities,
+                as: InsulinSensitivities.self
+            ),
+            let bgTargets: BGTargets = storage.retrieve(OpenAPS.Settings.bgTargets, as: BGTargets.self)
+        else {
+            debug(.service, "Failed to load Trio therapy settings for Tidepool upload")
+            return nil
+        }
+
+        let pumpSettings = settingsManager.pumpSettings
+        let preferences: Preferences? = storage.retrieve(OpenAPS.Settings.preferences, as: Preferences.self)
+
+        let basalRateSchedule = convertBasalProfile(basalProfile)
+        let carbRatioSchedule = convertCarbRatios(carbRatios)
+        let insulinSensitivitySchedule = convertInsulinSensitivities(insulinSensitivities)
+        let glucoseTargetRangeSchedule = convertBGTargets(bgTargets)
+
+        let pumpDevice = apsManager.pumpManager?.status.device
+        let bgUnit: HKUnit = settingsManager.settings.units == .mmolL ? .millimolesPerLiter : .milligramsPerDeciliter
+
+        // threshold_setting is always stored in mg/dL; TidepoolServiceKit calls
+        // convertTo(unit:) internally, so we pass it through in its native unit
+        let suspendThreshold: GlucoseThreshold? = preferences.map { prefs in
+            let thresholdValue = Double(prefs.threshold_setting)
+            return GlucoseThreshold(unit: .milligramsPerDeciliter, value: thresholdValue)
+        }
+
+        return StoredSettings(
+            date: Date(),
+            controllerTimeZone: TimeZone.current,
+            dosingEnabled: settingsManager.settings.closedLoop,
+            glucoseTargetRangeSchedule: glucoseTargetRangeSchedule,
+            preMealTargetRange: nil,
+            workoutTargetRange: nil,
+            overridePresets: nil,
+            scheduleOverride: nil,
+            preMealOverride: nil,
+            maximumBasalRatePerHour: Double(pumpSettings.maxBasal),
+            maximumBolus: Double(pumpSettings.maxBolus),
+            suspendThreshold: suspendThreshold,
+            insulinType: apsManager.pumpManager?.status.insulinType,
+            defaultRapidActingModel: convertInsulinModel(preferences: preferences, pumpSettings: pumpSettings),
+            basalRateSchedule: basalRateSchedule,
+            insulinSensitivitySchedule: insulinSensitivitySchedule,
+            carbRatioSchedule: carbRatioSchedule,
+            notificationSettings: nil,
+            controllerDevice: createControllerDevice(),
+            cgmDevice: cgmDevice,
+            pumpDevice: pumpDevice,
+            bloodGlucoseUnit: bgUnit,
+            syncIdentifier: contentBasedSyncIdentifier(
+                basalProfile: basalProfile,
+                carbRatios: carbRatios,
+                insulinSensitivities: insulinSensitivities,
+                bgTargets: bgTargets,
+                pumpSettings: pumpSettings,
+                preferences: preferences,
+                dosingEnabled: settingsManager.settings.closedLoop
+            )
+        )
+    }
+
+    private func convertBasalProfile(_ entries: [BasalProfileEntry]) -> BasalRateSchedule? {
+        let items = entries.map { entry in
+            let startTime = TimeInterval(entry.minutes * 60)
+            return RepeatingScheduleValue(startTime: startTime, value: Double(entry.rate))
+        }
+        return BasalRateSchedule(dailyItems: items, timeZone: TimeZone.current)
+    }
+
+    private func convertCarbRatios(_ carbRatios: CarbRatios) -> CarbRatioSchedule? {
+        let items = carbRatios.schedule.map { entry in
+            let startTime = TimeInterval(entry.offset * 60)
+            return RepeatingScheduleValue(startTime: startTime, value: Double(entry.ratio))
+        }
+        return CarbRatioSchedule(unit: .gram(), dailyItems: items, timeZone: TimeZone.current)
+    }
+
+    private func convertInsulinSensitivities(_ sensitivities: InsulinSensitivities) -> InsulinSensitivitySchedule? {
+        // sensitivities.units comes from the data model itself, not the user's display preference
+        let unit: HKUnit = sensitivities.units == .mgdL ? .milligramsPerDeciliter : .millimolesPerLiter
+        let items = sensitivities.sensitivities.map { entry in
+            let startTime = TimeInterval(entry.offset * 60)
+            return RepeatingScheduleValue(startTime: startTime, value: Double(entry.sensitivity))
+        }
+        return InsulinSensitivitySchedule(unit: unit, dailyItems: items, timeZone: TimeZone.current)
+    }
+
+    private func convertBGTargets(_ bgTargets: BGTargets) -> GlucoseRangeSchedule? {
+        // bgTargets.units comes from the data model itself, not the user's display preference
+        let unit: HKUnit = bgTargets.units == .mgdL ? .milligramsPerDeciliter : .millimolesPerLiter
+        let items = bgTargets.targets.map { entry in
+            let startTime = TimeInterval(entry.offset * 60)
+            let minValue = Double(entry.low)
+            let maxValue = Double(entry.high)
+            return RepeatingScheduleValue(startTime: startTime, value: DoubleRange(minValue: minValue, maxValue: maxValue))
+        }
+        let schedule = DailyQuantitySchedule(unit: unit, dailyItems: items, timeZone: TimeZone.current)
+        return schedule.map { GlucoseRangeSchedule(rangeSchedule: $0) }
+    }
+
+    private func convertInsulinModel(preferences: Preferences?, pumpSettings: PumpSettings) -> StoredInsulinModel? {
+        guard let curve = preferences?.curve else { return nil }
+
+        let modelType: StoredInsulinModel.ModelType
+        let preset: ExponentialInsulinModelPreset
+        switch curve {
+        case .bilinear,
+             .rapidActing:
+            modelType = .rapidAdult
+            preset = .rapidActingAdult
+        case .ultraRapid:
+            // Distinguish Fiasp vs Lyumjev using the pump's configured insulin type
+            let isLyumjev = apsManager.pumpManager?.status.insulinType == .lyumjev
+            modelType = isLyumjev ? .lyumjev : .fiasp
+            preset = isLyumjev ? .lyumjev : .fiasp
+        }
+
+        let dia = Double(pumpSettings.insulinActionCurve)
+
+        // Use custom peak time if enabled, otherwise fall back to LoopKit preset default
+        let peakActivity: TimeInterval
+        if let prefs = preferences, prefs.useCustomPeakTime {
+            peakActivity = .minutes(Double(prefs.insulinPeakTime))
+        } else {
+            peakActivity = preset.peakActivity
+        }
+
+        return StoredInsulinModel(
+            modelType: modelType,
+            delay: preset.delay,
+            actionDuration: .hours(dia),
+            peakActivity: peakActivity
+        )
+    }
+
+    /// Generates a deterministic UUID based on the content of the therapy settings.
+    /// If settings haven't changed, the same UUID is produced, enabling Tidepool
+    /// server-side deduplication via the origin ID.
+    private func contentBasedSyncIdentifier(
+        basalProfile: [BasalProfileEntry],
+        carbRatios: CarbRatios,
+        insulinSensitivities: InsulinSensitivities,
+        bgTargets: BGTargets,
+        pumpSettings: PumpSettings,
+        preferences: Preferences?,
+        dosingEnabled: Bool
+    ) -> UUID {
+        var hasher = SHA256()
+
+        for entry in basalProfile {
+            hasher.update(data: Data("\(entry.minutes):\(entry.rate)".utf8))
+        }
+        for entry in carbRatios.schedule {
+            hasher.update(data: Data("\(entry.offset):\(entry.ratio)".utf8))
+        }
+        for entry in insulinSensitivities.sensitivities {
+            hasher.update(data: Data("\(entry.offset):\(entry.sensitivity)".utf8))
+        }
+        for entry in bgTargets.targets {
+            hasher.update(data: Data("\(entry.offset):\(entry.low):\(entry.high)".utf8))
+        }
+
+        hasher.update(data: Data("maxBasal:\(pumpSettings.maxBasal)".utf8))
+        hasher.update(data: Data("maxBolus:\(pumpSettings.maxBolus)".utf8))
+
+        if let prefs = preferences {
+            hasher.update(data: Data("threshold:\(prefs.threshold_setting)".utf8))
+        }
+
+        hasher.update(data: Data("dosingEnabled:\(dosingEnabled)".utf8))
+
+        let digest = hasher.finalize()
+        let bytes = Array(digest.prefix(16))
+        return UUID(uuid: (
+            bytes[0], bytes[1], bytes[2], bytes[3],
+            bytes[4], bytes[5], bytes[6], bytes[7],
+            bytes[8], bytes[9], bytes[10], bytes[11],
+            bytes[12], bytes[13], bytes[14], bytes[15]
+        ))
+    }
+
+    private func createControllerDevice() -> StoredSettings.ControllerDevice {
+        let device = UIDevice.current
+        return StoredSettings.ControllerDevice(
+            name: "Trio",
+            systemName: device.systemName,
+            systemVersion: device.systemVersion,
+            model: device.model,
+            modelIdentifier: device.getDeviceId
+        )
+    }
+}
+
 // Service extension for rawValue
 extension Service {
     typealias RawValue = [String: Any]

+ 583 - 0
TrioTests/TidepoolTherapySettingsTests.swift

@@ -0,0 +1,583 @@
+import CryptoKit
+import HealthKit
+import LoopKit
+import Testing
+import TidepoolKit
+
+@testable import TidepoolServiceKit
+@testable import Trio
+
+// Both Trio and TidepoolServiceKit define mgPerDL,
+// causing ambiguity. Use HealthKit's native API to avoid the conflict.
+private let mgPerDL = HKUnit.gramUnit(with: .milli).unitDivided(by: HKUnit.literUnit(with: .deci))
+private let mmolPerL = HKUnit.moleUnit(with: .milli, molarMass: HKUnitMolarMassBloodGlucose).unitDivided(by: .liter())
+
+// MARK: - StoredSettings → Tidepool Datum Tests
+
+/// Tests that verify Trio's StoredSettings correctly converts to Tidepool's pumpSettings datum.
+/// These test the REAL TidepoolServiceKit conversion code path.
+@Suite("StoredSettings Tidepool Format Tests") struct StoredSettingsTidepoolFormatTests {
+    private static let encoder: JSONEncoder = {
+        let encoder = JSONEncoder.tidepool
+        encoder.outputFormatting.insert(.prettyPrinted)
+        encoder.outputFormatting.insert(.sortedKeys)
+        return encoder
+    }()
+
+    // MARK: - JSON Format
+
+    @Test("Pump settings JSON contains required fields") func pumpSettingsJSONFormat() {
+        let datum = StoredSettings.test.datumPumpSettings(for: "trio-user-123", hostIdentifier: "Trio", hostVersion: "0.6.0")
+        let data = try! Self.encoder.encode(datum)
+        let json = String(data: data, encoding: .utf8)!
+
+        let requiredFields = [
+            "\"type\" : \"pumpSettings\"",
+            "\"activeSchedule\" : \"Default\"",
+            "\"basalSchedules\"",
+            "\"bgTargets\"",
+            "\"carbRatios\"",
+            "\"insulinSensitivities\"",
+            "\"automatedDelivery\"",
+            "\"name\" : \"Trio\"",
+            "\"version\" : \"0.6.0\""
+        ]
+
+        for field in requiredFields {
+            #expect(json.contains(field), "Missing required field: \(field)")
+        }
+    }
+
+    @Test("Pump settings with minimal data") func pumpSettingsWithMinimalData() {
+        let datum = StoredSettings.minimal.datumPumpSettings(for: "test-user", hostIdentifier: "Trio", hostVersion: "0.6.0")
+        #expect(datum.activeScheduleName == "Default")
+        #expect(datum.origin?.name == "Trio")
+        #expect(datum.origin?.version == "0.6.0")
+    }
+
+    // MARK: - Schedule Naming
+
+    @Test("All schedules use 'Default' name") func scheduleNaming() {
+        let datum = StoredSettings.test.datumPumpSettings(for: "test", hostIdentifier: "Trio", hostVersion: "0.6.0")
+
+        #expect(datum.activeScheduleName == "Default")
+        #expect(datum.basalRateSchedules?.keys.count == 1)
+        #expect(datum.basalRateSchedules?["Default"] != nil)
+        #expect(datum.bloodGlucoseTargetSchedules?["Default"] != nil)
+        #expect(datum.carbohydrateRatioSchedules?["Default"] != nil)
+        #expect(datum.insulinSensitivitySchedules?["Default"] != nil)
+    }
+
+    // MARK: - Device Metadata
+
+    @Test("Pump device metadata is included") func pumpDeviceMetadata() {
+        let pumpDevice = HKDevice(
+            name: "Omnipod", manufacturer: "Insulet", model: "Dash",
+            hardwareVersion: "1.0", firmwareVersion: "2.9.0", softwareVersion: nil,
+            localIdentifier: "pod-123", udiDeviceIdentifier: nil
+        )
+
+        let settings = makeSettings(pumpDevice: pumpDevice)
+        let data = try! Self.encoder.encode(
+            settings.datumPumpSettings(for: "test", hostIdentifier: "Trio", hostVersion: "0.6.0")
+        )
+        let json = String(data: data, encoding: .utf8)!
+
+        #expect(json.contains("Omnipod"), "Missing pump device name")
+        #expect(json.contains("Insulet"), "Missing pump manufacturer")
+    }
+
+    @Test("CGM device metadata is included") func cgmDeviceMetadata() {
+        let cgmDevice = HKDevice(
+            name: "Dexcom G7", manufacturer: "Dexcom", model: "G7",
+            hardwareVersion: nil, firmwareVersion: "1.2.3", softwareVersion: "4.5.6",
+            localIdentifier: "CGM123", udiDeviceIdentifier: nil
+        )
+
+        let settings = makeSettings(cgmDevice: cgmDevice)
+        let datum = settings.datumCGMSettings(for: "test", hostIdentifier: "Trio", hostVersion: "0.6.0")
+        let data = try! Self.encoder.encode(datum)
+        let json = String(data: data, encoding: .utf8)!
+
+        #expect(json.contains("Dexcom G7"), "Missing CGM device name")
+        #expect(json.contains("Dexcom"), "Missing CGM manufacturer")
+    }
+
+    // MARK: - Suspend Threshold
+
+    @Test("Suspend threshold value is preserved") func suspendThreshold() {
+        let settings = makeSettings(
+            suspendThreshold: GlucoseThreshold(unit: mgPerDL, value: 70.0)
+        )
+        let datum = settings.datumPumpSettings(for: "test", hostIdentifier: "Trio", hostVersion: "0.6.0")
+
+        #expect(datum.bloodGlucoseSafetyLimit == 70, "Suspend threshold value should match")
+    }
+
+    @Test("Suspend threshold in mg/dL passes through for mmol/L user") func suspendThresholdMmolLUser() {
+        // threshold_setting is always stored in mg/dL even when user displays mmol/L.
+        // The adapter creates GlucoseThreshold in mg/dL; TidepoolServiceKit converts internally.
+        let settings = makeSettings(
+            suspendThreshold: GlucoseThreshold(unit: mgPerDL, value: 70.0),
+            bgUnit: mmolPerL
+        )
+        let datum = settings.datumPumpSettings(for: "test", hostIdentifier: "Trio", hostVersion: "0.6.0")
+
+        #expect(
+            datum.bloodGlucoseSafetyLimit == 70,
+            "Threshold in mg/dL should pass through correctly regardless of display unit"
+        )
+    }
+
+    // MARK: - Max Basal / Max Bolus
+
+    @Test("Maximum basal and bolus values are preserved") func maximumValues() {
+        let settings = makeSettings(maxBasal: 30.0, maxBolus: 25.0)
+        let datum = settings.datumPumpSettings(for: "test", hostIdentifier: "Trio", hostVersion: "0.6.0")
+
+        #expect(datum.basal?.rateMaximum?.value == 30.0, "Max basal should handle high values")
+        #expect(datum.bolus?.amountMaximum?.value == 25.0, "Max bolus should handle high values")
+    }
+
+    @Test("Minimum basal and bolus values are preserved") func minimumValues() {
+        let settings = makeSettings(maxBasal: 0.5, maxBolus: 1.0)
+        let datum = settings.datumPumpSettings(for: "test", hostIdentifier: "Trio", hostVersion: "0.6.0")
+
+        #expect(datum.basal?.rateMaximum?.value == 0.5, "Should preserve low max basal")
+        #expect(datum.bolus?.amountMaximum?.value == 1.0, "Should preserve low max bolus")
+    }
+
+    // MARK: - Automated Delivery Flag
+
+    @Test("Automated delivery flag reflects dosing state") func automatedDeliveryFlag() {
+        let enabled = makeSettings(dosingEnabled: true)
+        let disabled = makeSettings(dosingEnabled: false)
+
+        let enabledDatum = enabled.datumPumpSettings(for: "test", hostIdentifier: "Trio", hostVersion: "0.6.0")
+        let disabledDatum = disabled.datumPumpSettings(for: "test", hostIdentifier: "Trio", hostVersion: "0.6.0")
+
+        #expect(enabledDatum.automatedDelivery == true)
+        #expect(disabledDatum.automatedDelivery == false)
+    }
+
+    // MARK: - Unit Conversion
+
+    @Test("mmol/L values are converted to mg/dL by Tidepool") func mmolLUnitConversion() {
+        let targetSchedule = GlucoseRangeSchedule(
+            rangeSchedule: DailyQuantitySchedule(
+                unit: mmolPerL,
+                dailyItems: [RepeatingScheduleValue(
+                    startTime: 0,
+                    value: DoubleRange(minValue: 5.0, maxValue: 6.0)
+                )],
+                timeZone: .current
+            )!,
+            override: nil
+        )
+        let isfSchedule = InsulinSensitivitySchedule(
+            unit: mmolPerL,
+            dailyItems: [RepeatingScheduleValue(startTime: 0, value: 3.0)],
+            timeZone: .current
+        )
+
+        let settings = makeSettings(
+            glucoseTargetRangeSchedule: targetSchedule,
+            insulinSensitivitySchedule: isfSchedule,
+            bgUnit: mmolPerL
+        )
+        let datum = settings.datumPumpSettings(for: "test", hostIdentifier: "Trio", hostVersion: "0.6.0")
+
+        // Tidepool converts to mg/dL (5.0 mmol/L ≈ 90 mg/dL)
+        let target = datum.bloodGlucoseTargetSchedules?["Default"]?.first
+        #expect(abs((target?.low ?? 0) - 90) <= 1)
+        #expect(abs((target?.high ?? 0) - 108) <= 1)
+
+        let isf = datum.insulinSensitivitySchedules?["Default"]?.first
+        #expect(abs((isf?.amount ?? 0) - 54) <= 1)
+    }
+
+    // MARK: - Insulin Model
+
+    @Test("Insulin model preserves DIA and peak time") func insulinModel() {
+        let model = StoredInsulinModel(
+            modelType: .rapidAdult,
+            delay: .minutes(10),
+            actionDuration: .hours(8),
+            peakActivity: .minutes(65)
+        )
+        let settings = makeSettings(insulinModel: model)
+        let datum = settings.datumPumpSettings(for: "test", hostIdentifier: "Trio", hostVersion: "0.6.0")
+
+        #expect(datum.insulinModel != nil, "Insulin model should be present")
+        #expect(datum.insulinModel?.actionDuration == .hours(8), "DIA should match user setting")
+        #expect(datum.insulinModel?.actionPeakOffset == .minutes(65), "Peak time should match user setting")
+    }
+
+    @Test("Fiasp insulin model maps correctly") func fiaspInsulinModel() {
+        let model = StoredInsulinModel(
+            modelType: .fiasp,
+            delay: .minutes(10),
+            actionDuration: .hours(6),
+            peakActivity: .minutes(55)
+        )
+        let settings = makeSettings(insulinModel: model)
+        let datum = settings.datumPumpSettings(for: "test", hostIdentifier: "Trio", hostVersion: "0.6.0")
+
+        #expect(datum.insulinModel?.modelType == .fiasp, "Ultra-rapid should map to fiasp")
+        #expect(datum.insulinModel?.actionDuration == .hours(6))
+        #expect(datum.insulinModel?.actionPeakOffset == .minutes(55))
+    }
+
+    // MARK: - Helpers
+
+    private func makeSettings(
+        dosingEnabled: Bool = true,
+        glucoseTargetRangeSchedule: GlucoseRangeSchedule? = nil,
+        insulinSensitivitySchedule: InsulinSensitivitySchedule? = nil,
+        maxBasal: Double? = 5.0,
+        maxBolus: Double? = 10.0,
+        suspendThreshold: GlucoseThreshold? = nil,
+        insulinModel: StoredInsulinModel? = nil,
+        cgmDevice: HKDevice? = nil,
+        pumpDevice: HKDevice? = nil,
+        bgUnit: HKUnit = mgPerDL
+    ) -> StoredSettings {
+        let tz = TimeZone(secondsFromGMT: 0)!
+
+        let defaultTarget = GlucoseRangeSchedule(
+            rangeSchedule: DailyQuantitySchedule(
+                unit: mgPerDL,
+                dailyItems: [RepeatingScheduleValue(
+                    startTime: 0,
+                    value: DoubleRange(minValue: 100.0, maxValue: 110.0)
+                )],
+                timeZone: tz
+            )!,
+            override: nil
+        )
+
+        let defaultBasal = BasalRateSchedule(
+            dailyItems: [RepeatingScheduleValue(startTime: 0, value: 1.0)],
+            timeZone: tz
+        )!
+
+        let defaultISF = InsulinSensitivitySchedule(
+            unit: mgPerDL,
+            dailyItems: [RepeatingScheduleValue(startTime: 0, value: 45.0)],
+            timeZone: tz
+        )!
+
+        let defaultCarb = CarbRatioSchedule(
+            unit: .gram(),
+            dailyItems: [RepeatingScheduleValue(startTime: 0, value: 15.0)],
+            timeZone: tz
+        )!
+
+        return StoredSettings(
+            date: Date(),
+            controllerTimeZone: .current,
+            dosingEnabled: dosingEnabled,
+            glucoseTargetRangeSchedule: glucoseTargetRangeSchedule ?? defaultTarget,
+            preMealTargetRange: nil,
+            workoutTargetRange: nil,
+            overridePresets: nil,
+            scheduleOverride: nil,
+            preMealOverride: nil,
+            maximumBasalRatePerHour: maxBasal,
+            maximumBolus: maxBolus,
+            suspendThreshold: suspendThreshold,
+            insulinType: nil,
+            defaultRapidActingModel: insulinModel,
+            basalRateSchedule: defaultBasal,
+            insulinSensitivitySchedule: insulinSensitivitySchedule ?? defaultISF,
+            carbRatioSchedule: defaultCarb,
+            notificationSettings: nil,
+            controllerDevice: nil,
+            cgmDevice: cgmDevice,
+            pumpDevice: pumpDevice,
+            bloodGlucoseUnit: bgUnit,
+            syncIdentifier: UUID()
+        )
+    }
+}
+
+// MARK: - Conversion Logic Tests
+
+/// Tests for the conversion math used in BaseTidepoolManager.
+/// These verify the patterns used in the real adapter code.
+@Suite("BaseTidepoolManager Conversion Tests") struct BaseTidepoolManagerTests {
+    // MARK: - Basal Profile Conversion
+
+    @Test("Basal profile minutes convert to seconds") func basalProfileMinutesToSeconds() {
+        let entries: [(minutes: Int, expectedSeconds: TimeInterval)] = [
+            (0, 0),
+            (210, 12600),
+            (360, 21600),
+            (720, 43200),
+            (1125, 67500),
+            (1439, 86340)
+        ]
+
+        for (minutes, expected) in entries {
+            let startTime = TimeInterval(minutes * 60)
+            #expect(startTime == expected, "\(minutes) minutes should be \(expected) seconds")
+        }
+    }
+
+    @Test("Basal profile uses minutes field for start time") func basalProfileUsesMinutesField() {
+        let entries = [
+            BasalProfileEntry(start: "00:00:00", minutes: 0, rate: 1.0),
+            BasalProfileEntry(start: "06:00:00", minutes: 360, rate: 1.5),
+            BasalProfileEntry(start: "12:00:00", minutes: 720, rate: 1.25)
+        ]
+
+        let items = entries.map { entry in
+            RepeatingScheduleValue(
+                startTime: TimeInterval(entry.minutes * 60),
+                value: Double(entry.rate)
+            )
+        }
+        let schedule = BasalRateSchedule(dailyItems: items, timeZone: .current)
+
+        #expect(schedule != nil)
+        #expect(schedule?.items[0].startTime == 0)
+        #expect(schedule?.items[1].startTime == 21600)
+        #expect(schedule?.items[2].startTime == 43200)
+    }
+
+    // MARK: - Carb Ratio Conversion
+
+    @Test("Carb ratio offset converts to seconds") func carbRatioOffsetToSeconds() {
+        let entries = [
+            CarbRatioEntry(start: "00:00", offset: 0, ratio: 15.0),
+            CarbRatioEntry(start: "06:00", offset: 360, ratio: 12.0),
+            CarbRatioEntry(start: "12:00", offset: 720, ratio: 10.0)
+        ]
+
+        let items = entries.map { entry in
+            RepeatingScheduleValue(
+                startTime: TimeInterval(entry.offset * 60),
+                value: Double(entry.ratio)
+            )
+        }
+
+        #expect(items[0].startTime == 0)
+        #expect(items[1].startTime == 21600)
+        #expect(items[2].startTime == 43200)
+    }
+
+    // MARK: - ISF Conversion
+
+    @Test("ISF offset converts to seconds") func insulinSensitivityOffsetToSeconds() {
+        let entries = [
+            InsulinSensitivityEntry(sensitivity: 50.0, offset: 0, start: "00:00"),
+            InsulinSensitivityEntry(sensitivity: 45.0, offset: 480, start: "08:00")
+        ]
+
+        let items = entries.map { entry in
+            RepeatingScheduleValue(
+                startTime: TimeInterval(entry.offset * 60),
+                value: Double(entry.sensitivity)
+            )
+        }
+
+        #expect(items[0].startTime == 0)
+        #expect(items[1].startTime == 28800, "480 min = 28800 sec")
+    }
+
+    // MARK: - BG Target Conversion
+
+    @Test("BG target offset converts to seconds") func bgTargetOffsetToSeconds() {
+        let entries = [
+            BGTargetEntry(low: 100, high: 110, start: "00:00", offset: 0),
+            BGTargetEntry(low: 110, high: 120, start: "22:00", offset: 1320)
+        ]
+
+        #expect(TimeInterval(entries[0].offset * 60) == 0)
+        #expect(TimeInterval(entries[1].offset * 60) == 79200, "1320 min = 79200 sec")
+    }
+
+    @Test("BG target low and high values are preserved") func bgTargetLowHighValues() {
+        let entry = BGTargetEntry(low: 90, high: 120, start: "00:00", offset: 0)
+        #expect(Double(entry.low) == 90)
+        #expect(Double(entry.high) == 120)
+    }
+
+    // MARK: - Insulin Model Conversion
+
+    @Test("Preset peak times match expected values when custom peak disabled") func presetPeakTimes() {
+        // When useCustomPeakTime is false, should use LoopKit preset defaults
+        let rapidAdultPeak = ExponentialInsulinModelPreset.rapidActingAdult.peakActivity
+        let fiaspPeak = ExponentialInsulinModelPreset.fiasp.peakActivity
+
+        #expect(rapidAdultPeak == .minutes(75), "rapidActingAdult preset peak should be 75 min")
+        #expect(fiaspPeak == .minutes(55), "fiasp preset peak should be 55 min")
+    }
+
+    @Test("Custom peak time range boundaries") func customPeakTimeRange() {
+        // insulinPeakTime picker: min 35, max 120, step 1 (minutes)
+        let minPeak: TimeInterval = .minutes(35)
+        let maxPeak: TimeInterval = .minutes(120)
+
+        #expect(minPeak == 2100, "35 minutes = 2100 seconds")
+        #expect(maxPeak == 7200, "120 minutes = 7200 seconds")
+    }
+
+    @Test("DIA range boundaries") func diaRange() {
+        // insulinActionCurve picker: min 5, max 10, step 0.5 (hours)
+        let minDIA: TimeInterval = .hours(5)
+        let maxDIA: TimeInterval = .hours(10)
+
+        #expect(minDIA == 18000, "5 hours = 18000 seconds")
+        #expect(maxDIA == 36000, "10 hours = 36000 seconds")
+    }
+
+    // MARK: - Content-Based Sync Identifier
+
+    @Test("Same settings produce the same sync identifier") func syncIdentifierDeterminism() {
+        let id1 = computeTestSyncId(maxBasal: "5.0", maxBolus: "10.0", dosingEnabled: true)
+        let id2 = computeTestSyncId(maxBasal: "5.0", maxBolus: "10.0", dosingEnabled: true)
+        #expect(id1 == id2, "Same settings should produce the same sync identifier")
+    }
+
+    @Test("Different settings produce different sync identifiers") func syncIdentifierChanges() {
+        let baseline = computeTestSyncId(maxBasal: "5.0", maxBolus: "10.0", dosingEnabled: true)
+        let changedBasal = computeTestSyncId(maxBasal: "6.0", maxBolus: "10.0", dosingEnabled: true)
+        let changedDosing = computeTestSyncId(maxBasal: "5.0", maxBolus: "10.0", dosingEnabled: false)
+
+        #expect(baseline != changedBasal, "Different maxBasal should produce different ID")
+        #expect(baseline != changedDosing, "Different dosingEnabled should produce different ID")
+        #expect(changedBasal != changedDosing, "All three should be unique")
+    }
+
+    // MARK: - Helpers
+
+    /// Replicates the SHA-256 hash algorithm from BaseTidepoolManager.contentBasedSyncIdentifier
+    private func computeTestSyncId(maxBasal: String, maxBolus: String, dosingEnabled: Bool) -> UUID {
+        var hasher = SHA256()
+        hasher.update(data: Data("0:1.0".utf8)) // basal entry
+        hasher.update(data: Data("0:15".utf8)) // carb ratio
+        hasher.update(data: Data("0:50".utf8)) // ISF
+        hasher.update(data: Data("0:100:110".utf8)) // BG target
+        hasher.update(data: Data("maxBasal:\(maxBasal)".utf8))
+        hasher.update(data: Data("maxBolus:\(maxBolus)".utf8))
+        hasher.update(data: Data("threshold:100".utf8))
+        hasher.update(data: Data("dosingEnabled:\(dosingEnabled)".utf8))
+        let digest = hasher.finalize()
+        let bytes = Array(digest.prefix(16))
+        return UUID(uuid: (
+            bytes[0], bytes[1], bytes[2], bytes[3],
+            bytes[4], bytes[5], bytes[6], bytes[7],
+            bytes[8], bytes[9], bytes[10], bytes[11],
+            bytes[12], bytes[13], bytes[14], bytes[15]
+        ))
+    }
+}
+
+// MARK: - Test Fixtures
+
+private extension StoredSettings {
+    static var test: StoredSettings {
+        let tz = TimeZone(secondsFromGMT: 0)!
+
+        let pumpDevice = HKDevice(
+            name: "Omnipod", manufacturer: "Insulet", model: "Dash",
+            hardwareVersion: "1.0", firmwareVersion: "2.9.0", softwareVersion: nil,
+            localIdentifier: "pod-serial-123", udiDeviceIdentifier: nil
+        )
+
+        return StoredSettings(
+            date: Date(),
+            controllerTimeZone: TimeZone(identifier: "America/Los_Angeles")!,
+            dosingEnabled: true,
+            glucoseTargetRangeSchedule: GlucoseRangeSchedule(
+                rangeSchedule: DailyQuantitySchedule(
+                    unit: mgPerDL,
+                    dailyItems: [RepeatingScheduleValue(startTime: 0, value: DoubleRange(minValue: 100.0, maxValue: 110.0))],
+                    timeZone: tz
+                )!,
+                override: nil
+            ),
+            preMealTargetRange: nil,
+            workoutTargetRange: nil,
+            overridePresets: nil,
+            scheduleOverride: nil,
+            preMealOverride: nil,
+            maximumBasalRatePerHour: 5.0,
+            maximumBolus: 10.0,
+            suspendThreshold: nil,
+            insulinType: .humalog,
+            defaultRapidActingModel: nil,
+            basalRateSchedule: BasalRateSchedule(dailyItems: [
+                RepeatingScheduleValue(startTime: 0, value: 1.0),
+                RepeatingScheduleValue(startTime: 21600, value: 1.5),
+                RepeatingScheduleValue(startTime: 43200, value: 1.25),
+                RepeatingScheduleValue(startTime: 64800, value: 1.0)
+            ], timeZone: tz)!,
+            insulinSensitivitySchedule: InsulinSensitivitySchedule(
+                unit: mgPerDL,
+                dailyItems: [RepeatingScheduleValue(startTime: 0, value: 45.0)],
+                timeZone: tz
+            )!,
+            carbRatioSchedule: CarbRatioSchedule(
+                unit: .gram(),
+                dailyItems: [RepeatingScheduleValue(startTime: 0, value: 15.0)],
+                timeZone: tz
+            )!,
+            notificationSettings: nil,
+            controllerDevice: nil,
+            cgmDevice: nil,
+            pumpDevice: pumpDevice,
+            bloodGlucoseUnit: mgPerDL,
+            syncIdentifier: UUID()
+        )
+    }
+
+    static var minimal: StoredSettings {
+        let tz = TimeZone(secondsFromGMT: 0)!
+
+        return StoredSettings(
+            date: Date(),
+            controllerTimeZone: .current,
+            dosingEnabled: true,
+            glucoseTargetRangeSchedule: GlucoseRangeSchedule(
+                rangeSchedule: DailyQuantitySchedule(
+                    unit: mgPerDL,
+                    dailyItems: [RepeatingScheduleValue(startTime: 0, value: DoubleRange(minValue: 100.0, maxValue: 110.0))],
+                    timeZone: tz
+                )!,
+                override: nil
+            ),
+            preMealTargetRange: nil,
+            workoutTargetRange: nil,
+            overridePresets: nil,
+            scheduleOverride: nil,
+            preMealOverride: nil,
+            maximumBasalRatePerHour: nil,
+            maximumBolus: nil,
+            suspendThreshold: nil,
+            insulinType: nil,
+            defaultRapidActingModel: nil,
+            basalRateSchedule: BasalRateSchedule(
+                dailyItems: [RepeatingScheduleValue(startTime: 0, value: 1.0)],
+                timeZone: tz
+            )!,
+            insulinSensitivitySchedule: InsulinSensitivitySchedule(
+                unit: mgPerDL,
+                dailyItems: [RepeatingScheduleValue(startTime: 0, value: 45.0)],
+                timeZone: tz
+            )!,
+            carbRatioSchedule: CarbRatioSchedule(
+                unit: .gram(),
+                dailyItems: [RepeatingScheduleValue(startTime: 0, value: 15.0)],
+                timeZone: tz
+            )!,
+            notificationSettings: nil,
+            controllerDevice: nil,
+            cgmDevice: nil,
+            pumpDevice: nil,
+            bloodGlucoseUnit: mgPerDL,
+            syncIdentifier: UUID()
+        )
+    }
+}