@@ -92,7 +92,7 @@ struct SentryConfig: Codable, Equatable, Sendable {
9292 dsn = try container. decodeIfPresent ( String . self, forKey: . dsn) ?? " "
9393 environment = try container. decodeIfPresent ( String . self, forKey: . environment) ?? " production "
9494 debug = try container. decodeIfPresent ( Bool . self, forKey: . debug) ?? false
95- let sampleRate = try container. decodeIfPresent ( Double . self , forKey: . traces_sample_rate) ?? 0
95+ let sampleRate = try container. decodeLenientDoubleIfPresent ( forKey: . traces_sample_rate) ?? 0
9696 traces_sample_rate = min ( max ( sampleRate, 0 ) , 1 )
9797 }
9898}
@@ -166,6 +166,22 @@ struct FanCurveConfig: Codable, Equatable, Sendable {
166166 self . slew_rate = slew_rate
167167 self . weights = weights
168168 }
169+
170+ enum CodingKeys : String , CodingKey {
171+ case name, sensors, points, hysteresis, slew_rate, weights
172+ }
173+
174+ // Custom decoding so `hysteresis`/`slew_rate` are optional (default rather than
175+ // keyNotFound) and accept TOML integers — see decodeLenientDoubleIfPresent (#9).
176+ init ( from decoder: Decoder ) throws {
177+ let container = try decoder. container ( keyedBy: CodingKeys . self)
178+ name = try container. decode ( String . self, forKey: . name)
179+ sensors = try container. decodeIfPresent ( [ String ] . self, forKey: . sensors) ?? [ ]
180+ points = try container. decode ( [ [ FanPointValue ] ] . self, forKey: . points)
181+ hysteresis = try container. decodeLenientDoubleIfPresent ( forKey: . hysteresis) ?? 0
182+ slew_rate = try container. decodeLenientDoubleIfPresent ( forKey: . slew_rate)
183+ weights = try container. decodeLenientDoubleDictionaryIfPresent ( forKey: . weights)
184+ }
169185}
170186
171187enum FanPointValue : Codable , Equatable , Sendable {
@@ -178,6 +194,13 @@ enum FanPointValue: Codable, Equatable, Sendable {
178194 self = . number( number)
179195 return
180196 }
197+ // TOML keeps integers and floats as distinct types, so `[50, "max"]` decodes
198+ // the 50 as Int, not Double. Accept it as a number instead of falling through
199+ // to the String branch and rejecting the whole curve.
200+ if let integer = try ? container. decode ( Int . self) {
201+ self = . number( Double ( integer) )
202+ return
203+ }
181204 let string = try container. decode ( String . self) . trimmingCharacters ( in: . whitespacesAndNewlines) . lowercased ( )
182205 if string == " max " {
183206 self = . maximum
@@ -197,6 +220,42 @@ enum FanPointValue: Codable, Equatable, Sendable {
197220 }
198221}
199222
223+ /// TOML keeps integers and floats as distinct types. A hand-written `100` for a
224+ /// Double config field would otherwise throw a type mismatch and — via loadConfig's
225+ /// catch — silently reset the entire config to defaults (issue #9). Accept integers
226+ /// as Doubles; a genuinely wrong type still surfaces as a decoding error.
227+ extension KeyedDecodingContainer {
228+ func decodeLenientDoubleIfPresent( forKey key: Key ) throws -> Double ? {
229+ guard contains ( key) else { return nil }
230+ if let value = try ? decode ( Double . self, forKey: key) { return value }
231+ if let integer = try ? decode ( Int . self, forKey: key) { return Double ( integer) }
232+ return try decode ( Double . self, forKey: key)
233+ }
234+
235+ /// Same integer leniency for a `[String: Double]` field (fan curve weights):
236+ /// `weights = { cpu = 2 }` decodes the values as Int and would otherwise throw,
237+ /// discarding the whole config.
238+ func decodeLenientDoubleDictionaryIfPresent( forKey key: Key ) throws -> [ String : Double ] ? {
239+ guard contains ( key) else { return nil }
240+ if let value = try ? decode ( [ String : Double ] . self, forKey: key) { return value }
241+ if let integers = try ? decode ( [ String : Int ] . self, forKey: key) { return integers. mapValues ( Double . init) }
242+ // Mixed integer/float entries: decode value-by-value (a wrong type still throws).
243+ let nested = try nestedContainer ( keyedBy: LenientDictKey . self, forKey: key)
244+ var result : [ String : Double ] = [ : ]
245+ for entry in nested. allKeys {
246+ result [ entry. stringValue] = try nested. decodeLenientDoubleIfPresent ( forKey: entry)
247+ }
248+ return result
249+ }
250+ }
251+
252+ private struct LenientDictKey : CodingKey {
253+ var stringValue : String
254+ var intValue : Int ?
255+ init ? ( stringValue: String ) { self . stringValue = stringValue }
256+ init ? ( intValue: Int ) { self . stringValue = String ( intValue) ; self . intValue = intValue }
257+ }
258+
200259struct SafetyConfig : Codable , Equatable , Sendable {
201260 var temp_ceiling : Double
202261 /// Advanced opt-in: allows `fan set --force` to target below the fan's reported
@@ -211,7 +270,7 @@ struct SafetyConfig: Codable, Equatable, Sendable {
211270 // Defensive decoding — see BatteryConfig.
212271 init ( from decoder: Decoder ) throws {
213272 let container = try decoder. container ( keyedBy: CodingKeys . self)
214- temp_ceiling = try container. decodeIfPresent ( Double . self , forKey: . temp_ceiling)
273+ temp_ceiling = try container. decodeLenientDoubleIfPresent ( forKey: . temp_ceiling)
215274 ?? FanSafetyGuard . defaultCeilingCelsius
216275 allow_below_minimum = try container. decodeIfPresent ( Bool . self, forKey: . allow_below_minimum) ?? false
217276 }
@@ -256,6 +315,10 @@ public final class SmctlDaemon: @unchecked Sendable {
256315 private static let alertHistoryLimit = 50
257316 private var lastEvaluation : Date ?
258317 private var lastError : String ?
318+ /// Set when the on-disk config fails to parse. The daemon keeps running on
319+ /// defaults, so without this the failure was invisible — `daemon status` showed
320+ /// `last error: -` while silently ignoring the user's config (issue #9).
321+ private var configError : String ?
259322 private var lastWriteError : String ?
260323 private var timer : DispatchSourceTimer ?
261324 private var fanTimer : DispatchSourceTimer ?
@@ -376,7 +439,9 @@ public final class SmctlDaemon: @unchecked Sendable {
376439 periodSeconds: SmctlDaemon . period,
377440 configPath: configPath,
378441 lastEvaluation: lastEvaluation,
379- lastError: lastError
442+ // A config parse failure is more actionable than a transient runtime
443+ // error and persists until the user fixes the file, so it wins.
444+ lastError: configError ?? lastError
380445 )
381446 }
382447 }
@@ -411,7 +476,9 @@ public final class SmctlDaemon: @unchecked Sendable {
411476 func reloadConfig( ) {
412477 queue. sync {
413478 let wasForcing = config. battery. force_discharge
414- config = Self . loadConfig ( path: configPath)
479+ let loaded = Self . loadConfig ( path: configPath)
480+ config = loaded. config
481+ configError = loaded. error
415482 SentryReporter . startIfConfigured ( config: config. sentry)
416483 let policy = ( try ? SleepPolicy . parse ( config. battery. sleep_policy) ) ?? . strict
417484 sleepMachine. setPolicy ( policy)
@@ -1112,16 +1179,16 @@ public final class SmctlDaemon: @unchecked Sendable {
11121179 return " unknown "
11131180 }
11141181
1115- private static func loadConfig( path: String ) -> DaemonConfig {
1182+ private static func loadConfig( path: String ) -> ( config : DaemonConfig , error : String ? ) {
11161183 guard FileManager . default. fileExists ( atPath: path) else {
1117- return DaemonConfig ( )
1184+ return ( DaemonConfig ( ) , nil )
11181185 }
11191186 do {
11201187 let text = try String ( contentsOfFile: path, encoding: . utf8)
1121- return try TOMLDecoder ( ) . decode ( DaemonConfig . self, from: text)
1188+ return ( try TOMLDecoder ( ) . decode ( DaemonConfig . self, from: text) , nil )
11221189 } catch {
11231190 logger. error ( " Unable to parse \( path, privacy: . public) : \( String ( describing: error) , privacy: . public) " )
1124- return DaemonConfig ( )
1191+ return ( DaemonConfig ( ) , " config at \( path ) failed to parse, running on defaults: \( String ( describing : error ) ) " )
11251192 }
11261193 }
11271194
0 commit comments