/// SQLite persistence via GRDB. /// Schema: Session -> Measurement -> DataPoint import Foundation import GRDB // MARK: - Records struct Session: Codable, FetchableRecord, MutablePersistableRecord { var id: Int64? var startedAt: Date var label: String? var notes: String? mutating func didInsert(_ inserted: InsertionSuccess) { id = inserted.rowID } } struct Measurement: Codable, FetchableRecord, MutablePersistableRecord { var id: Int64? var sessionId: Int64 var type: String var startedAt: Date var config: Data? var resultSummary: Data? mutating func didInsert(_ inserted: InsertionSuccess) { id = inserted.rowID } } struct DataPoint: Codable, FetchableRecord, MutablePersistableRecord { var id: Int64? var measurementId: Int64 var index: Int var payload: Data mutating func didInsert(_ inserted: InsertionSuccess) { id = inserted.rowID } } // MARK: - Database manager final class Storage: @unchecked Sendable { static let shared = Storage() private let dbQueue: DatabaseQueue private init() { let dir = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask)[0] let path = dir.appendingPathComponent("eis4.sqlite").path do { dbQueue = try DatabaseQueue(path: path) try migrate() } catch { fatalError("Database init failed: \(error)") } } private func migrate() throws { var migrator = DatabaseMigrator() migrator.registerMigration("v1") { db in try db.create(table: "session") { t in t.autoIncrementedPrimaryKey("id") t.column("startedAt", .datetime).notNull() t.column("label", .text) t.column("notes", .text) } try db.create(table: "measurement") { t in t.autoIncrementedPrimaryKey("id") t.column("sessionId", .integer).notNull() .references("session", onDelete: .cascade) t.column("type", .text).notNull() t.column("startedAt", .datetime).notNull() t.column("config", .blob) t.column("resultSummary", .blob) } try db.create(table: "dataPoint") { t in t.autoIncrementedPrimaryKey("id") t.column("measurementId", .integer).notNull() .references("measurement", onDelete: .cascade) t.column("index", .integer).notNull() t.column("payload", .blob).notNull() } } try migrator.migrate(dbQueue) } // MARK: - Sessions func createSession(label: String? = nil) throws -> Session { try dbQueue.write { db in var s = Session(startedAt: Date(), label: label) try s.insert(db) return s } } func fetchSessions() throws -> [Session] { try dbQueue.read { db in try Session.order(Column("startedAt").desc).fetchAll(db) } } func deleteSession(_ id: Int64) throws { try dbQueue.write { db in try db.execute(sql: "DELETE FROM session WHERE id = ?", arguments: [id]) } } func updateSession(_ id: Int64, label: String?, notes: String?) throws { try dbQueue.write { db in try db.execute( sql: "UPDATE session SET label = ?, notes = ? WHERE id = ?", arguments: [label, notes, id] ) } } // MARK: - Measurements func addMeasurement( sessionId: Int64, type: MeasurementType, config: (any Encodable)? = nil ) throws -> Measurement { let configData: Data? = if let config { try JSONEncoder().encode(config) } else { nil } return try dbQueue.write { db in var m = Measurement( sessionId: sessionId, type: type.rawValue, startedAt: Date(), config: configData ) try m.insert(db) return m } } func setMeasurementResult(_ measurementId: Int64, result: any Encodable) throws { try dbQueue.write { db in let data = try JSONEncoder().encode(result) try db.execute( sql: "UPDATE measurement SET resultSummary = ? WHERE id = ?", arguments: [data, measurementId] ) } } func fetchMeasurements(sessionId: Int64) throws -> [Measurement] { try dbQueue.read { db in try Measurement .filter(Column("sessionId") == sessionId) .order(Column("startedAt").asc) .fetchAll(db) } } func deleteMeasurement(_ id: Int64) throws { try dbQueue.write { db in try db.execute(sql: "DELETE FROM measurement WHERE id = ?", arguments: [id]) } } func dataPointCount(measurementId: Int64) throws -> Int { try dbQueue.read { db in try DataPoint .filter(Column("measurementId") == measurementId) .fetchCount(db) } } func measurementCount(sessionId: Int64) throws -> Int { try dbQueue.read { db in try Measurement .filter(Column("sessionId") == sessionId) .fetchCount(db) } } // MARK: - Data points func addDataPoint(measurementId: Int64, index: Int, point: T) throws { let payload = try JSONEncoder().encode(point) try dbQueue.write { db in var dp = DataPoint( measurementId: measurementId, index: index, payload: payload ) try dp.insert(db) } } func addDataPoints(measurementId: Int64, points: [(index: Int, value: T)]) throws { let encoder = JSONEncoder() try dbQueue.write { db in for (idx, val) in points { let payload = try encoder.encode(val) var dp = DataPoint(measurementId: measurementId, index: idx, payload: payload) try dp.insert(db) } } } func fetchDataPoints(measurementId: Int64) throws -> [DataPoint] { try dbQueue.read { db in try DataPoint .filter(Column("measurementId") == measurementId) .order(Column("index").asc) .fetchAll(db) } } /// Decode stored data points into typed values. func fetchTypedPoints(measurementId: Int64, as type: T.Type) throws -> [T] { let rows = try fetchDataPoints(measurementId: measurementId) let decoder = JSONDecoder() return try rows.map { try decoder.decode(T.self, from: $0.payload) } } // MARK: - Observation (for SwiftUI live updates) func observeDataPoints( measurementId: Int64, onChange: @escaping ([DataPoint]) -> Void ) -> DatabaseCancellable { let observation = ValueObservation.tracking { db in try DataPoint .filter(Column("measurementId") == measurementId) .order(Column("index").asc) .fetchAll(db) } return observation.start(in: dbQueue, onError: { _ in }, onChange: onChange) } func observeSessions(onChange: @escaping ([Session]) -> Void) -> DatabaseCancellable { let observation = ValueObservation.tracking { db in try Session.order(Column("startedAt").desc).fetchAll(db) } return observation.start(in: dbQueue, onError: { _ in }, onChange: onChange) } // MARK: - TOML export/import private static let tomlDateFmt: DateFormatter = { let f = DateFormatter() f.dateFormat = "yyyy-MM-dd HH:mm:ss" f.locale = Locale(identifier: "en_US_POSIX") f.timeZone = TimeZone.current return f }() func exportSession(_ id: Int64) throws -> String { let session = try dbQueue.read { db in try Session.fetchOne(db, key: id) } guard let session else { throw StorageError.notFound } var out = "[session]\n" out += "name = \(tomlQuote(session.label ?? ""))\n" out += "notes = \(tomlQuote(session.notes ?? ""))\n" out += "created_at = \(tomlQuote(Self.tomlDateFmt.string(from: session.startedAt)))\n" let measurements = try fetchMeasurements(sessionId: id) for m in measurements { out += "\n[[measurement]]\n" out += "type = \(tomlQuote(m.type))\n" out += "created_at = \(tomlQuote(Self.tomlDateFmt.string(from: m.startedAt)))\n" if let configData = m.config, let config = try? JSONSerialization.jsonObject(with: configData) as? [String: Any] { out += "\n[measurement.params]\n" for (k, v) in config.sorted(by: { $0.key < $1.key }) { out += "\(k) = \(tomlValue(v))\n" } } guard let mid = m.id else { continue } let mtype = MeasurementType(rawValue: m.type) let points = try fetchDataPoints(measurementId: mid) let decoder = JSONDecoder() switch mtype { case .eis: for dp in points { if let p = try? decoder.decode(EisPoint.self, from: dp.payload) { out += "\n[[measurement.data]]\n" out += "\"Frequency (Hz)\" = \(tomlFloat(p.freqHz))\n" out += "\"|Z| (Ohm)\" = \(tomlFloat(p.magOhms))\n" out += "\"Phase (deg)\" = \(tomlFloat(p.phaseDeg))\n" out += "\"Re (Ohm)\" = \(tomlFloat(p.zReal))\n" out += "\"Im (Ohm)\" = \(tomlFloat(p.zImag))\n" } } case .lsv: for dp in points { if let p = try? decoder.decode(LsvPoint.self, from: dp.payload) { out += "\n[[measurement.data]]\n" out += "\"Voltage (mV)\" = \(tomlFloat(p.vMv))\n" out += "\"Current (uA)\" = \(tomlFloat(p.iUa))\n" } } case .amp: for dp in points { if let p = try? decoder.decode(AmpPoint.self, from: dp.payload) { out += "\n[[measurement.data]]\n" out += "\"Time (ms)\" = \(tomlFloat(p.tMs))\n" out += "\"Current (uA)\" = \(tomlFloat(p.iUa))\n" } } case .chlorine: for dp in points { if let p = try? decoder.decode(ClPoint.self, from: dp.payload) { out += "\n[[measurement.data]]\n" out += "\"Time (ms)\" = \(tomlFloat(p.tMs))\n" out += "\"Current (uA)\" = \(tomlFloat(p.iUa))\n" out += "\"Phase\" = \(p.phase)\n" } } if let resultData = m.resultSummary, let r = try? decoder.decode(ClResult.self, from: resultData) { out += "\n[measurement.result]\n" out += "\"Free Cl (uA)\" = \(tomlFloat(r.iFreeUa))\n" out += "\"Total Cl (uA)\" = \(tomlFloat(r.iTotalUa))\n" } case .ph: for dp in points { if let p = try? decoder.decode(PhResult.self, from: dp.payload) { out += "\n[[measurement.data]]\n" out += "\"OCP (mV)\" = \(tomlFloat(p.vOcpMv))\n" out += "\"pH\" = \(tomlFloat(p.ph))\n" out += "\"Temperature (C)\" = \(tomlFloat(p.tempC))\n" } } case nil: break } } return out } func importSession(from toml: String) throws -> Int64 { let parsed = TOMLParser.parse(toml) guard let sessionDict = parsed["session"] as? [String: Any] else { throw StorageError.parseError("missing [session]") } let name = sessionDict["name"] as? String ?? "" let notes = sessionDict["notes"] as? String ?? "" var session = Session( startedAt: Self.tomlDateFmt.date(from: sessionDict["created_at"] as? String ?? "") ?? Date(), label: name.isEmpty ? nil : name, notes: notes.isEmpty ? nil : notes ) try dbQueue.write { db in try session.insert(db) } guard let sid = session.id else { throw StorageError.parseError("insert failed") } guard let measurements = parsed["measurement"] as? [[String: Any]] else { return sid } let encoder = JSONEncoder() for mDict in measurements { let typeStr = mDict["type"] as? String ?? "eis" let createdStr = mDict["created_at"] as? String ?? "" let created = Self.tomlDateFmt.date(from: createdStr) ?? Date() var configData: Data? = nil if let params = mDict["params"] as? [String: Any] { configData = try? JSONSerialization.data(withJSONObject: params) } var meas = Measurement( sessionId: sid, type: typeStr, startedAt: created, config: configData ) try dbQueue.write { db in try meas.insert(db) } guard let mid = meas.id else { continue } let mtype = MeasurementType(rawValue: typeStr) guard let dataRows = mDict["data"] as? [[String: Any]] else { continue } try dbQueue.write { db in for (idx, row) in dataRows.enumerated() { let payload: Data switch mtype { case .eis: payload = try encoder.encode(EisPoint( freqHz: floatVal(row, "Frequency (Hz)"), magOhms: floatVal(row, "|Z| (Ohm)"), phaseDeg: floatVal(row, "Phase (deg)"), zReal: floatVal(row, "Re (Ohm)"), zImag: floatVal(row, "Im (Ohm)"), rtiaMagBefore: 0, rtiaMagAfter: 0, revMag: 0, revPhase: 0, pctErr: 0 )) case .lsv: payload = try encoder.encode(LsvPoint( vMv: floatVal(row, "Voltage (mV)"), iUa: floatVal(row, "Current (uA)") )) case .amp: payload = try encoder.encode(AmpPoint( tMs: floatVal(row, "Time (ms)"), iUa: floatVal(row, "Current (uA)") )) case .chlorine: payload = try encoder.encode(ClPoint( tMs: floatVal(row, "Time (ms)"), iUa: floatVal(row, "Current (uA)"), phase: UInt8(intVal(row, "Phase")) )) case .ph: payload = try encoder.encode(PhResult( vOcpMv: floatVal(row, "OCP (mV)"), ph: floatVal(row, "pH"), tempC: floatVal(row, "Temperature (C)") )) case nil: continue } var dp = DataPoint(measurementId: mid, index: idx, payload: payload) try dp.insert(db) } } if mtype == .chlorine, let resultDict = mDict["result"] as? [String: Any] { let r = ClResult( iFreeUa: floatVal(resultDict, "Free Cl (uA)"), iTotalUa: floatVal(resultDict, "Total Cl (uA)") ) try setMeasurementResult(mid, result: r) } if mtype == .ph, let dataRows = mDict["data"] as? [[String: Any]], let first = dataRows.first { let r = PhResult( vOcpMv: floatVal(first, "OCP (mV)"), ph: floatVal(first, "pH"), tempC: floatVal(first, "Temperature (C)") ) try setMeasurementResult(mid, result: r) } } return sid } } enum StorageError: Error { case notFound case parseError(String) } // MARK: - TOML helpers private func tomlQuote(_ s: String) -> String { let escaped = s.replacingOccurrences(of: "\\", with: "\\\\") .replacingOccurrences(of: "\"", with: "\\\"") .replacingOccurrences(of: "\n", with: "\\n") return "\"\(escaped)\"" } private func tomlFloat(_ v: Float) -> String { if v == v.rounded() && abs(v) < 1e15 { return String(format: "%.1f", v) } return String(v) } private func tomlValue(_ v: Any) -> String { switch v { case let s as String: return tomlQuote(s) case let n as NSNumber: if CFGetTypeID(n) == CFBooleanGetTypeID() { return n.boolValue ? "true" : "false" } let d = n.doubleValue if d == d.rounded() && abs(d) < 1e15 && !"\(n)".contains(".") { return "\(n)" } return String(format: "%g", d) default: return tomlQuote("\(v)") } } private func floatVal(_ dict: [String: Any], _ key: String) -> Float { if let n = dict[key] as? NSNumber { return n.floatValue } return 0 } private func intVal(_ dict: [String: Any], _ key: String) -> Int { if let n = dict[key] as? NSNumber { return n.intValue } return 0 } // MARK: - Minimal TOML parser enum TOMLParser { static func parse(_ input: String) -> [String: Any] { var root: [String: Any] = [:] var currentSection: [String] = [] var arrayCounters: [String: Int] = [:] let lines = input.components(separatedBy: .newlines) for line in lines { let trimmed = line.trimmingCharacters(in: .whitespaces) if trimmed.isEmpty || trimmed.hasPrefix("#") { continue } if let match = parseArrayTable(trimmed) { let parts = match.components(separatedBy: ".") let rootKey = parts[0] let counterKey = match if arrayCounters[counterKey] == nil { setNested(&root, path: [rootKey], value: [[String: Any]]()) arrayCounters[counterKey] = 0 } else { arrayCounters[counterKey]! += 1 } if var arr = getNested(root, path: [rootKey]) as? [[String: Any]] { if arr.count <= arrayCounters[counterKey]! { arr.append([:]) setNested(&root, path: [rootKey], value: arr) } } currentSection = parts continue } if let match = parseTable(trimmed) { currentSection = match.components(separatedBy: ".") continue } if let (key, value) = parseKeyValue(trimmed) { let path = resolvePath(currentSection, arrayCounters: arrayCounters) var target = (getNested(root, path: path) as? [String: Any]) ?? [:] target[key] = value if path.isEmpty { for (k, v) in target { root[k] = v } } else { setNested(&root, path: path, value: target) } } } return root } private static func parseArrayTable(_ line: String) -> String? { guard line.hasPrefix("[[") && line.hasSuffix("]]") else { return nil } let inner = line.dropFirst(2).dropLast(2).trimmingCharacters(in: .whitespaces) return inner.isEmpty ? nil : inner } private static func parseTable(_ line: String) -> String? { guard line.hasPrefix("[") && line.hasSuffix("]") && !line.hasPrefix("[[") else { return nil } let inner = line.dropFirst(1).dropLast(1).trimmingCharacters(in: .whitespaces) return inner.isEmpty ? nil : inner } private static func parseKeyValue(_ line: String) -> (String, Any)? { guard let eqIdx = line.firstIndex(of: "=") else { return nil } var key = String(line[line.startIndex.. Any { if s.hasPrefix("\"") && s.hasSuffix("\"") { var inner = String(s.dropFirst(1).dropLast(1)) inner = inner.replacingOccurrences(of: "\\n", with: "\n") inner = inner.replacingOccurrences(of: "\\\"", with: "\"") inner = inner.replacingOccurrences(of: "\\\\", with: "\\") return inner } if s == "true" { return true } if s == "false" { return false } if s.contains(".") || s.contains("e") || s.contains("E") { if let d = Double(s) { return NSNumber(value: d) } } if let i = Int(s) { return NSNumber(value: i) } return s } private static func resolvePath( _ section: [String], arrayCounters: [String: Int] ) -> [String] { guard !section.isEmpty else { return [] } let rootKey = section[0] let fullKey = section.joined(separator: ".") var path: [String] = [rootKey] if let idx = arrayCounters[rootKey] { path.append("\(idx)") } if section.count > 1 { let subKey = section[1...].joined(separator: ".") if let idx = arrayCounters[fullKey] { path.append(subKey) path.append("\(idx)") } else { for part in section[1...] { path.append(part) } } } return path } private static func getNested(_ dict: [String: Any], path: [String]) -> Any? { var current: Any = dict for component in path { if let idx = Int(component), let arr = current as? [Any], idx < arr.count { current = arr[idx] } else if let d = current as? [String: Any], let val = d[component] { current = val } else { return nil } } return current } private static func setNested(_ dict: inout [String: Any], path: [String], value: Any) { guard !path.isEmpty else { return } if path.count == 1 { dict[path[0]] = value return } let key = path[0] let rest = Array(path[1...]) if let idxStr = rest.first, let idx = Int(idxStr), var arr = dict[key] as? [Any] { while arr.count <= idx { arr.append([String: Any]()) } if rest.count == 1 { arr[idx] = value } else { var sub = (arr[idx] as? [String: Any]) ?? [:] setNested(&sub, path: Array(rest[1...]), value: value) arr[idx] = sub } dict[key] = arr } else { var sub = (dict[key] as? [String: Any]) ?? [:] setNested(&sub, path: rest, value: value) dict[key] = sub } } }