Skip to content

Instantly share code, notes, and snippets.

@coreequip
Last active December 20, 2025 17:58
Show Gist options
  • Select an option

  • Save coreequip/65abd649502dae2db513b6f4bd9ec0ce to your computer and use it in GitHub Desktop.

Select an option

Save coreequip/65abd649502dae2db513b6f4bd9ec0ce to your computer and use it in GitHub Desktop.
A simple Swift CLI tool for macOS that monitors SwitchBot CO2 sensors via Bluetooth.

CO2 Monitor (Swift)

A simple Swift CLI tool for macOS that monitors SwitchBot CO2 sensors via Bluetooth. It periodically checks the air quality and sends a system notification if the CO2 level exceeds a defined threshold (default: 1000 ppm), helping you ensure a healthy indoor environment.

Prerequisites

  • macOS (tested on Tahoe)
  • Xcode Command Line Tools installed (usually present, or install via xcode-select --install)
  • A SwitchBot CO2 sensor (Meter) - https://www.amazon.de/dp/B0DBVDWB6G

Setup

1. Compile

First, compile the Swift source code into an executable binary:

swiftc co2mon.swift -o co2mon

2. Scan for Device

Run the tool in scan mode to discover your SwitchBot device and find its UUID:

./co2mon scan

Wait for your device to appear (look for manufacturer ID 0x0969 or SwitchBot name) and copy its UUID (e.g., E0227306-704B-97A2-E948-03200FF8A480). Press Ctrl+C to stop scanning.

3. Install (LaunchAgent)

Use the provided installer script to set up the monitor as a background service (LaunchAgent). This ensures it runs automatically and restarts on login.

Replace <UUID> with the UUID you copied in the previous step:

chmod +x install.sh
./install.sh <UUID>

This script will:

  1. Verify the co2mon binary exists.
  2. Create a LaunchAgent plist at ~/Library/LaunchAgents/de.coreequip.co2monitor.plist.
  3. Load and start the service immediately.

Usage & Logs

The service runs in the background. You can check the output logs here:

tail -f ~/Library/Logs/co2monitor.log

If the CO2 level exceeds the default threshold (1000 ppm), it will send a system notification.

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)
}
}
#!/bin/bash
# Check if UUID is provided
if [ -z "$1" ]; then
echo "Usage: $0 <UUID>"
echo "Example: $0 E0227306-704B-97A2-E948-03200FF8A480"
exit 1
fi
UUID="$1"
SCRIPT_DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" && pwd )"
BINARY_PATH="$SCRIPT_DIR/co2mon"
PLIST_LABEL="de.coreequip.co2monitor"
PLIST_FILE="$HOME/Library/LaunchAgents/$PLIST_LABEL.plist"
# Check if co2mon binary exists
if [ ! -f "$BINARY_PATH" ]; then
echo "Error: 'co2mon' binary not found in $SCRIPT_DIR"
echo "Please build it first (e.g., 'swiftc co2mon.swift -o co2mon')"
exit 1
fi
# Ensure binary is executable
if [ ! -x "$BINARY_PATH" ]; then
echo "Making 'co2mon' executable..."
chmod +x "$BINARY_PATH"
fi
echo "Creating LaunchAgent plist..."
cat <<EOF > "$PLIST_FILE"
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>Label</key>
<string>$PLIST_LABEL</string>
<key>ProgramArguments</key>
<array>
<string>$BINARY_PATH</string>
<string>$UUID</string>
</array>
<key>RunAtLoad</key>
<true/>
<key>KeepAlive</key>
<true/>
<key>StandardOutPath</key>
<string>$HOME/Library/Logs/co2monitor.log</string>
<key>StandardErrorPath</key>
<string>$HOME/Library/Logs/co2monitor.err</string>
</dict>
</plist>
EOF
echo "Plist created at: $PLIST_FILE"
# Unload if already loaded (ignore error if not loaded)
launchctl bootout "gui/$(id -u)" "$PLIST_FILE" 2>/dev/null || true
echo "Loading LaunchAgent..."
if launchctl bootstrap "gui/$(id -u)" "$PLIST_FILE"; then
echo "✅ Successfully installed and loaded $PLIST_LABEL"
echo "Logs are available at ~/Library/Logs/co2monitor.log"
else
echo "❌ Failed to load LaunchAgent. Check the plist or logs."
exit 1
fi
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment