|
import Foundation |
|
import CoreBluetooth |
|
|
|
// --- Constants --- |
|
let version = "0.1" |
|
let switchBotMfrId: UInt16 = 0x0969 |
|
let switchBotServiceUUID = CBUUID(string: "0000fd3d-0000-1000-8000-00805f9b34fb") |
|
let cooldownSeconds: TimeInterval = 15 * 60 // 15 Minutes |
|
|
|
// --- Helpers --- |
|
struct ParsedData: Equatable { |
|
var co2: Int? |
|
var temp: Double? |
|
var humidity: Int? |
|
var battery: Int? |
|
|
|
// For console output comparison |
|
static func == (lhs: ParsedData, rhs: ParsedData) -> Bool { |
|
return lhs.co2 == rhs.co2 && |
|
lhs.temp == rhs.temp && |
|
lhs.humidity == rhs.humidity && |
|
lhs.battery == rhs.battery |
|
} |
|
} |
|
|
|
class CO2Monitor: NSObject, CBCentralManagerDelegate { |
|
|
|
private var centralManager: CBCentralManager! |
|
private var targetUUID: UUID? |
|
private var threshold: Int = 1000 |
|
private var mode: Mode |
|
private var lastData: ParsedData? |
|
private var lastNotificationTime: Date = Date.distantPast |
|
|
|
enum Mode { |
|
case scan |
|
case monitor |
|
} |
|
|
|
init(mode: Mode, targetUUID: String? = nil, threshold: Int = 1000) { |
|
self.mode = mode |
|
if let uuidString = targetUUID { |
|
self.targetUUID = UUID(uuidString: uuidString) |
|
} |
|
self.threshold = threshold |
|
|
|
super.init() |
|
|
|
// Initialize Bluetooth |
|
// queue: nil means main queue |
|
self.centralManager = CBCentralManager(delegate: self, queue: nil) |
|
} |
|
|
|
func start() { |
|
print("Initializing Bluetooth...") |
|
// The real start happens in centralManagerDidUpdateState |
|
RunLoop.main.run() |
|
} |
|
|
|
// MARK: - CBCentralManagerDelegate |
|
|
|
func centralManagerDidUpdateState(_ central: CBCentralManager) { |
|
switch central.state { |
|
case .poweredOn: |
|
print("Bluetooth is ON.") |
|
startScanning() |
|
case .poweredOff: |
|
print("Bluetooth is OFF. Please enable it.") |
|
exit(1) |
|
case .unauthorized: |
|
print("Bluetooth is unauthorized. Check System Settings > Privacy.") |
|
exit(1) |
|
case .unsupported: |
|
print("Bluetooth is not supported on this device.") |
|
exit(1) |
|
default: |
|
print("Bluetooth state unknown: \(central.state.rawValue)") |
|
} |
|
} |
|
|
|
private func startScanning() { |
|
if mode == .scan { |
|
print("Scanning for SwitchBot devices (ID 0x0969)...") |
|
} else if let uuid = targetUUID { |
|
print("Monitoring device: \(uuid.uuidString)") |
|
print("CO2 Threshold: \(threshold) ppm") |
|
print("Waiting for data (Press Ctrl+C to exit)...") |
|
} |
|
|
|
// Scan for everything, we filter in the callback |
|
centralManager.scanForPeripherals(withServices: nil, options: [CBCentralManagerScanOptionAllowDuplicatesKey: true]) |
|
} |
|
|
|
func centralManager(_ central: CBCentralManager, didDiscover peripheral: CBPeripheral, advertisementData: [String : Any], rssi RSSI: NSNumber) { |
|
|
|
// Logic split based on Mode |
|
if mode == .scan { |
|
handleScanMode(peripheral: peripheral, advData: advertisementData, rssi: RSSI) |
|
} else { |
|
handleMonitorMode(peripheral: peripheral, advData: advertisementData) |
|
} |
|
} |
|
|
|
// MARK: - Handlers |
|
|
|
private func handleScanMode(peripheral: CBPeripheral, advData: [String: Any], rssi: NSNumber) { |
|
// Filter for SwitchBot Manufacturer ID |
|
guard let mfrData = advData[CBAdvertisementDataManufacturerDataKey] as? Data else { return } |
|
|
|
// Verify Manufacturer ID (First 2 bytes little endian) |
|
// 0x69 0x09 => 0x0969 |
|
if mfrData.count < 2 { return } |
|
let mfrId = mfrData.withUnsafeBytes { $0.load(as: UInt16.self) } |
|
|
|
if mfrId == switchBotMfrId { |
|
print("\n--- SwitchBot Found! ---") |
|
print("Name: \(peripheral.name ?? "Unknown")") |
|
print("UUID: \(peripheral.identifier.uuidString)") |
|
print("RSSI: \(rssi) dBm") |
|
|
|
if let parsed = parseAdvertisement(advData: advData) { |
|
var output = "Data: " |
|
if let co2 = parsed.co2 { output += "CO2: \(co2) ppm " } |
|
if let temp = parsed.temp { output += String(format: "Temp: %.1f°C ", temp) } |
|
if let hum = parsed.humidity { output += "Hum: \(hum)% " } |
|
print(output) |
|
|
|
if parsed.co2 != nil { |
|
print("✅ Found! This is a CO2 sensor.") |
|
} |
|
} |
|
} |
|
} |
|
|
|
private func handleMonitorMode(peripheral: CBPeripheral, advData: [String: Any]) { |
|
guard peripheral.identifier == targetUUID else { return } |
|
|
|
guard let data = parseAdvertisement(advData: advData) else { return } |
|
|
|
// Only print if data changed |
|
if data != lastData { |
|
lastData = data |
|
|
|
let timestamp = DateFormatter.localizedString(from: Date(), dateStyle: .none, timeStyle: .medium) |
|
var parts: [String] = [] |
|
|
|
if let co2 = data.co2 { parts.append("CO2: \(co2) ppm") } |
|
if let temp = data.temp { parts.append(String(format: "Temp: %.1f°C", temp)) } |
|
if let hum = data.humidity { parts.append("RH: \(hum)%") } |
|
if let bat = data.battery { parts.append("Batt: \(bat)%") } |
|
|
|
if !parts.isEmpty { |
|
print("[\(timestamp)] " + parts.joined(separator: " | ")) |
|
} |
|
|
|
// Notification Logic |
|
if let co2 = data.co2, co2 > threshold { |
|
checkAndNotify(co2: co2) |
|
} |
|
} |
|
} |
|
|
|
private func checkAndNotify(co2: Int) { |
|
let now = Date() |
|
if now.timeIntervalSince(lastNotificationTime) > cooldownSeconds { |
|
sendNotification(co2: co2) |
|
lastNotificationTime = now |
|
} |
|
} |
|
|
|
private func sendNotification(co2: Int) { |
|
let title = "⚠️ Air Quality Alert" |
|
let message = "CO2 Level is critical: \(co2) ppm" |
|
|
|
// Print to console |
|
print("\n*** \(title) ***") |
|
print("\(message)\n") |
|
|
|
// Try MacOS notification via AppleScript (safe for CLI) |
|
let script = "display notification \"\(message)\" with title \"\(title)\" sound name \"default\"" |
|
let process = Process() |
|
process.launchPath = "/usr/bin/osascript" |
|
process.arguments = ["-e", script] |
|
process.launch() |
|
} |
|
|
|
// MARK: - Parsing Logic |
|
|
|
private func parseAdvertisement(advData: [String: Any]) -> ParsedData? { |
|
var parsed = ParsedData() |
|
|
|
let mfrData = advData[CBAdvertisementDataManufacturerDataKey] as? Data |
|
let svcDataDict = advData[CBAdvertisementDataServiceDataKey] as? [CBUUID: Data] |
|
let svcData = svcDataDict?[switchBotServiceUUID] |
|
|
|
// 1. Battery from Service Data |
|
if let sData = svcData, sData.count >= 3 { |
|
let battery = Int(sData[2] & 0x7F) |
|
if battery != 0 { |
|
parsed.battery = battery |
|
} |
|
} |
|
|
|
// 2. Temp/Humidity |
|
// Preferred: Manufacturer Data [8...10] (relative to data start), else Service Data [3...5] |
|
// Note: mfrData includes the 2-byte ID, so Python index 8 is index 10 here. |
|
var tempRaw: Data? = nil |
|
|
|
if let mData = mfrData, mData.count >= 13 { |
|
tempRaw = mData.subdata(in: 10..<13) |
|
} else if let sData = svcData, sData.count >= 6 { |
|
tempRaw = sData.subdata(in: 3..<6) |
|
} |
|
|
|
if let tData = tempRaw, tData.count == 3 { |
|
// Byte 0: decimal part / 10 |
|
// Byte 1: whole part (lower 7 bits), bit 7 is sign |
|
// Byte 2: humidity (lower 7 bits) |
|
|
|
let b0 = tData[0] |
|
let b1 = tData[1] |
|
let b2 = tData[2] |
|
|
|
let sign: Double = (b1 & 0x80) > 0 ? 1.0 : -1.0 |
|
let whole = Double(b1 & 0x7F) |
|
let decimal = Double(b0 & 0x0F) / 10.0 |
|
|
|
let tempC = sign * (whole + decimal) |
|
let humidity = Int(b2 & 0x7F) |
|
|
|
// Filter invalid 0/0 readings |
|
if tempC != 0 || humidity != 0 { |
|
parsed.temp = tempC |
|
parsed.humidity = humidity |
|
} |
|
} |
|
|
|
// 3. CO2 from Manufacturer Data [13...14] (Big Endian) |
|
// Note: mfrData includes the 2-byte ID, so Python index 13 is index 15 here. |
|
if let mData = mfrData, mData.count >= 17 { |
|
// Bytes 15 and 16 |
|
let b15 = UInt16(mData[15]) |
|
let b16 = UInt16(mData[16]) |
|
let co2 = Int((b15 << 8) | b16) |
|
|
|
if co2 > 0 && co2 <= 10000 { |
|
parsed.co2 = co2 |
|
} |
|
} |
|
|
|
if parsed.co2 == nil && parsed.temp == nil && parsed.battery == nil { |
|
return nil |
|
} |
|
|
|
return parsed |
|
} |
|
} |
|
|
|
// --- Main CLI Logic --- |
|
|
|
func printBanner() { |
|
print(#""" |
|
_________ ________ ________ _____ .__ __ |
|
\_ ___ \ \_____ \ \_____ \ / \ ____ ____ |__|/ |_ ___________ |
|
/ \ \/ / | \ / ____/ / \ / \ / _ \ / \| \ __\/ _ \_ __ \ |
|
\ \____/ | \/ \/ Y ( <_> ) | \ || | ( <_> ) | \/ |
|
\______ /\_______ /\_______ \____|__ /\____/|___| /__||__| \____/|__| |
|
\/ \/ \/ \/ \/ |
|
"""#) |
|
} |
|
|
|
func printUsage() { |
|
let title = "CO2 Monitor v\(version) by core.equip" |
|
let underline = String(repeating: "~", count: title.count) |
|
print(""" |
|
\(title) |
|
\(underline) |
|
Usage: |
|
co2mon <command> [options] |
|
|
|
Commands: |
|
(no args) Show this help message. |
|
scan Scan for SwitchBot devices. |
|
<UUID> Monitor a specific device by UUID. |
|
Example: E0227306-704B-97A2-E948-03200FF8A480 |
|
|
|
Options: |
|
<Threshold> (Optional) CO2 threshold for notifications in monitor mode. |
|
Default: 1000 ppm. |
|
""") |
|
} |
|
|
|
printBanner() |
|
let args = CommandLine.arguments |
|
|
|
if args.count < 2 { |
|
printUsage() |
|
exit(0) |
|
} |
|
|
|
let command = args[1] |
|
|
|
if command.lowercased() == "scan" { |
|
let monitor = CO2Monitor(mode: .scan) |
|
monitor.start() |
|
} else { |
|
// Assume command is UUID |
|
// Simple UUID validation |
|
if UUID(uuidString: command) != nil { |
|
var threshold = 1000 |
|
if args.count > 2 { |
|
if let t = Int(args[2]) { |
|
threshold = t |
|
} else { |
|
print("Error: Invalid threshold provided. Using default 1000.") |
|
} |
|
} |
|
|
|
let monitor = CO2Monitor(mode: .monitor, targetUUID: command, threshold: threshold) |
|
monitor.start() |
|
} else { |
|
print("Error: Invalid command or UUID.") |
|
printUsage() |
|
exit(1) |
|
} |
|
} |