From 5da59f56c43dc9cebb3177401b6e8d0012598dc7 Mon Sep 17 00:00:00 2001 From: "google-labs-jules[bot]" <161369871+google-labs-jules[bot]@users.noreply.github.com> Date: Wed, 7 Jan 2026 22:29:47 +0000 Subject: [PATCH 1/3] Add Battery Usermod support - Update data model to parse UserMods ("u") and "Battery level" - Create DeviceBatteryView to display battery icon and percentage - Integrate battery view into DeviceInfoTwoRows - Add unit tests for UserMods decoding --- wled/Model/DeviceApi/Response/Info.swift | 3 +- wled/Model/DeviceApi/Response/UserMods.swift | 27 +++++++ wled/View/DeviceBatteryView.swift | 49 +++++++++++++ wled/View/DeviceInfoTwoRows.swift | 5 ++ wledTests/UserModsDecodingTests.swift | 74 ++++++++++++++++++++ 5 files changed, 157 insertions(+), 1 deletion(-) create mode 100644 wled/Model/DeviceApi/Response/UserMods.swift create mode 100644 wled/View/DeviceBatteryView.swift create mode 100644 wledTests/UserModsDecodingTests.swift diff --git a/wled/Model/DeviceApi/Response/Info.swift b/wled/Model/DeviceApi/Response/Info.swift index c47d9a6..59ee39c 100644 --- a/wled/Model/DeviceApi/Response/Info.swift +++ b/wled/Model/DeviceApi/Response/Info.swift @@ -44,7 +44,7 @@ struct Info: Decodable { var mac : String? var ipAddress : String? // Missing: u - UserMods - + var userMods: UserMods? enum CodingKeys: String, CodingKey { case leds @@ -80,5 +80,6 @@ struct Info: Decodable { case product case mac case ipAddress = "ip" + case userMods = "u" } } diff --git a/wled/Model/DeviceApi/Response/UserMods.swift b/wled/Model/DeviceApi/Response/UserMods.swift new file mode 100644 index 0000000..4fe6566 --- /dev/null +++ b/wled/Model/DeviceApi/Response/UserMods.swift @@ -0,0 +1,27 @@ +import Foundation + +struct UserMods: Decodable { + var batteryLevel: Int? + var batteryVoltage: Double? + + enum CodingKeys: String, CodingKey { + case batteryLevel = "Battery level" + case batteryVoltage = "Battery voltage" + } + + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + + // The API returns arrays [85], [3.9]. We want the first element. + // Battery level might be Int or Double. + if let levelArray = try? container.decode([Int].self, forKey: .batteryLevel), let first = levelArray.first { + batteryLevel = first + } else if let levelArray = try? container.decode([Double].self, forKey: .batteryLevel), let first = levelArray.first { + batteryLevel = Int(first) + } + + if let voltageArray = try? container.decode([Double].self, forKey: .batteryVoltage), let first = voltageArray.first { + batteryVoltage = first + } + } +} diff --git a/wled/View/DeviceBatteryView.swift b/wled/View/DeviceBatteryView.swift new file mode 100644 index 0000000..3a8cd4b --- /dev/null +++ b/wled/View/DeviceBatteryView.swift @@ -0,0 +1,49 @@ +import SwiftUI + +struct DeviceBatteryView: View { + let batteryLevel: Int + + var body: some View { + HStack(spacing: 3) { + Image(systemName: getBatteryIconName(level: batteryLevel)) + Text("\(batteryLevel)%") + } + .font(.caption2) + .foregroundStyle(getBatteryColor(level: batteryLevel)) + } + + private func getBatteryIconName(level: Int) -> String { + switch level { + case 0...10: + return "battery.0" + case 11...35: + return "battery.25" + case 36...60: + return "battery.50" + case 61...85: + return "battery.75" + default: + return "battery.100" + } + } + + private func getBatteryColor(level: Int) -> Color { + if level <= 20 { + return .red + } else { + return .primary + } + } +} + +struct DeviceBatteryView_Previews: PreviewProvider { + static var previews: some View { + VStack { + DeviceBatteryView(batteryLevel: 5) + DeviceBatteryView(batteryLevel: 25) + DeviceBatteryView(batteryLevel: 50) + DeviceBatteryView(batteryLevel: 75) + DeviceBatteryView(batteryLevel: 100) + } + } +} diff --git a/wled/View/DeviceInfoTwoRows.swift b/wled/View/DeviceInfoTwoRows.swift index 069f0a2..bdf5159 100644 --- a/wled/View/DeviceInfoTwoRows.swift +++ b/wled/View/DeviceInfoTwoRows.swift @@ -30,6 +30,11 @@ struct DeviceInfoTwoRows: View { .lineLimit(1) .fixedSize() .lineSpacing(0) + + if let batteryLevel = device.stateInfo?.info.userMods?.batteryLevel { + DeviceBatteryView(batteryLevel: batteryLevel) + } + let signalStrength = Int(device.stateInfo?.info.wifi.signal ?? 0) Label { Text( diff --git a/wledTests/UserModsDecodingTests.swift b/wledTests/UserModsDecodingTests.swift new file mode 100644 index 0000000..56303b5 --- /dev/null +++ b/wledTests/UserModsDecodingTests.swift @@ -0,0 +1,74 @@ + +import Testing +import Foundation +@testable import WLED + +struct UserModsDecodingTests { + + @Test func testDecodeUserMods() throws { + let json = """ + { + "u": { + "Battery level": [85], + "Battery voltage": [3.9] + } + } + """.data(using: .utf8)! + + // We can't decode UserMods directly easily because it's part of Info which has many required fields. + // However, we can create a wrapper struct just for testing decoding of UserMods, + // or we can test decoding of UserMods itself if we conform it to Decodable (which it is). + + let decoder = JSONDecoder() + + struct TestContainer: Decodable { + let u: UserMods + } + + let result = try decoder.decode(TestContainer.self, from: json) + + #expect(result.u.batteryLevel == 85) + #expect(result.u.batteryVoltage == 3.9) + } + + @Test func testDecodeUserModsWithDoubleLevel() throws { + let json = """ + { + "u": { + "Battery level": [85.5], + "Battery voltage": [3.9] + } + } + """.data(using: .utf8)! + + let decoder = JSONDecoder() + + struct TestContainer: Decodable { + let u: UserMods + } + + let result = try decoder.decode(TestContainer.self, from: json) + + #expect(result.u.batteryLevel == 85) // casted to Int + #expect(result.u.batteryVoltage == 3.9) + } + + @Test func testDecodeUserModsMissingData() throws { + let json = """ + { + "u": {} + } + """.data(using: .utf8)! + + let decoder = JSONDecoder() + + struct TestContainer: Decodable { + let u: UserMods + } + + let result = try decoder.decode(TestContainer.self, from: json) + + #expect(result.u.batteryLevel == nil) + #expect(result.u.batteryVoltage == nil) + } +} From e7194800aefad0877cd5609e5082f4902fa82422 Mon Sep 17 00:00:00 2001 From: "google-labs-jules[bot]" <161369871+google-labs-jules[bot]@users.noreply.github.com> Date: Wed, 7 Jan 2026 22:33:38 +0000 Subject: [PATCH 2/3] Add Battery Usermod support - Update data model to parse UserMods ("u") and "Battery level" - Create DeviceBatteryView to display battery icon and percentage - Integrate battery view into DeviceInfoTwoRows - Add unit tests for UserMods decoding - Remove stale comment in Info.swift --- wled/Model/DeviceApi/Response/Info.swift | 1 - 1 file changed, 1 deletion(-) diff --git a/wled/Model/DeviceApi/Response/Info.swift b/wled/Model/DeviceApi/Response/Info.swift index 59ee39c..7139392 100644 --- a/wled/Model/DeviceApi/Response/Info.swift +++ b/wled/Model/DeviceApi/Response/Info.swift @@ -43,7 +43,6 @@ struct Info: Decodable { var product : String? var mac : String? var ipAddress : String? - // Missing: u - UserMods var userMods: UserMods? enum CodingKeys: String, CodingKey { From 7b7e73e0eb51b8b5ccbd01dfd0f0d8acea4d6cd0 Mon Sep 17 00:00:00 2001 From: "google-labs-jules[bot]" <161369871+google-labs-jules[bot]@users.noreply.github.com> Date: Wed, 7 Jan 2026 22:38:01 +0000 Subject: [PATCH 3/3] Add Battery Usermod support - Update data model to parse UserMods ("u") and "Battery level" - Create DeviceBatteryView to display battery icon and percentage - Integrate battery view into DeviceInfoTwoRows - Add unit tests for UserMods decoding - Remove stale comment in Info.swift - Update PreviewData to show battery in online device preview --- wled/View/Preview/PreviewData.swift | 20 ++++++++++++++++---- 1 file changed, 16 insertions(+), 4 deletions(-) diff --git a/wled/View/Preview/PreviewData.swift b/wled/View/Preview/PreviewData.swift index dfabc5e..fa571bc 100644 --- a/wled/View/Preview/PreviewData.swift +++ b/wled/View/Preview/PreviewData.swift @@ -19,7 +19,7 @@ struct PreviewData { // MARK: - Devices static var onlineDevice: DeviceWithState { - createDevice(name: "WLED Beam", ip: "10.0.1.12") + createDevice(name: "WLED Beam", ip: "10.0.1.12", batteryLevel: 85) } static var offlineDevice: DeviceWithState { @@ -50,7 +50,8 @@ struct PreviewData { ip: String, version: String = "0.14.0", isHidden: Bool = false, - color: [Int] = [255, 160, 0] + color: [Int] = [255, 160, 0], + batteryLevel: Int? = nil ) -> DeviceWithState { let macAddress = "mock:mac:\(ip)" let request: NSFetchRequest = Device.fetchRequest() @@ -73,7 +74,7 @@ struct PreviewData { let deviceWithState = DeviceWithState(initialDevice: device) deviceWithState.websocketStatus = .connected - deviceWithState.stateInfo = .mock(name: name, version: version, color: color) + deviceWithState.stateInfo = .mock(name: name, version: version, color: color, batteryLevel: batteryLevel) // Save to ensure ID is stable if viewContext.hasChanges { @@ -104,11 +105,21 @@ struct PreviewData { // MARK: - Mock Data Extensions extension DeviceStateInfo { - static func mock(name: String, version: String, color: [Int]) -> DeviceStateInfo { + static func mock(name: String, version: String, color: [Int], batteryLevel: Int? = nil) -> DeviceStateInfo { let r = color.indices.contains(0) ? color[0] : 255 let g = color.indices.contains(1) ? color[1] : 160 let b = color.indices.contains(2) ? color[2] : 0 + var userModsJson = "" + if let batteryLevel = batteryLevel { + userModsJson = """ + , "u": { + "Battery level": [\(batteryLevel)], + "Battery voltage": [3.9] + } + """ + } + let json = """ { "state": { @@ -123,6 +134,7 @@ extension DeviceStateInfo { "ver": "\(version)", "leds": { "count": 30, "pwr": 0, "fps": 0, "maxpwr": 0, "maxseg": 0 }, "wifi": { "signal": -60 } + \(userModsJson) } } """