mirror of
https://github.com/morgan9e/SensorReader
synced 2026-04-14 00:14:33 +09:00
init
This commit is contained in:
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"colors" : [
|
||||
{
|
||||
"idiom" : "universal"
|
||||
}
|
||||
],
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
{
|
||||
"images" : [
|
||||
{
|
||||
"idiom" : "universal",
|
||||
"platform" : "ios",
|
||||
"size" : "1024x1024"
|
||||
}
|
||||
],
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
}
|
||||
}
|
||||
6
EnvSensorReader/Assets.xcassets/Contents.json
Normal file
6
EnvSensorReader/Assets.xcassets/Contents.json
Normal file
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
}
|
||||
}
|
||||
152
EnvSensorReader/BLEManager.swift
Normal file
152
EnvSensorReader/BLEManager.swift
Normal file
@@ -0,0 +1,152 @@
|
||||
import Foundation
|
||||
import CoreBluetooth
|
||||
import Combine
|
||||
|
||||
class BLEManager: NSObject, ObservableObject {
|
||||
@Published var readings: [SensorReading] = []
|
||||
@Published var isScanning = false
|
||||
@Published var bluetoothState: CBManagerState = .unknown
|
||||
|
||||
private var centralManager: CBCentralManager!
|
||||
private var seenNonces: Set<String> = []
|
||||
|
||||
private let deviceName = "EnvSensor"
|
||||
private let companyID: UInt16 = 0xFFFF
|
||||
|
||||
override init() {
|
||||
super.init()
|
||||
centralManager = CBCentralManager(delegate: self, queue: nil)
|
||||
}
|
||||
|
||||
func startScanning() {
|
||||
guard centralManager.state == .poweredOn else {
|
||||
print("Bluetooth not ready")
|
||||
return
|
||||
}
|
||||
|
||||
seenNonces.removeAll()
|
||||
centralManager.scanForPeripherals(withServices: nil, options: [CBCentralManagerScanOptionAllowDuplicatesKey: true])
|
||||
isScanning = true
|
||||
print("Started scanning for EnvSensor devices...")
|
||||
}
|
||||
|
||||
func stopScanning() {
|
||||
centralManager.stopScan()
|
||||
isScanning = false
|
||||
print("Stopped scanning")
|
||||
}
|
||||
|
||||
private func parseManufacturerData(_ data: Data) -> (nonce: UInt16, temp: Double, hum: Double, pres: Double, voltage: Double, current: Double)? {
|
||||
guard data.count == 16 else { return nil }
|
||||
|
||||
let bytes = [UInt8](data)
|
||||
|
||||
// Parse according to struct format: '<HhHIHi'
|
||||
// H = unsigned short (2 bytes)
|
||||
// h = signed short (2 bytes)
|
||||
// I = unsigned int (4 bytes)
|
||||
// i = signed int (4 bytes)
|
||||
|
||||
let nonce = UInt16(bytes[0]) | (UInt16(bytes[1]) << 8)
|
||||
|
||||
let tempRaw = Int16(bitPattern: UInt16(bytes[2]) | (UInt16(bytes[3]) << 8))
|
||||
let temp = Double(tempRaw) / 100.0
|
||||
|
||||
let humRaw = UInt16(bytes[4]) | (UInt16(bytes[5]) << 8)
|
||||
let hum = Double(humRaw) / 100.0
|
||||
|
||||
let presRaw = UInt32(bytes[6]) | (UInt32(bytes[7]) << 8) | (UInt32(bytes[8]) << 16) | (UInt32(bytes[9]) << 24)
|
||||
let pres = Double(presRaw) / 10.0
|
||||
|
||||
let voltageRaw = UInt16(bytes[10]) | (UInt16(bytes[11]) << 8)
|
||||
let voltage = Double(voltageRaw) / 100.0
|
||||
|
||||
let currentRaw = Int32(bitPattern: UInt32(bytes[12]) | (UInt32(bytes[13]) << 8) | (UInt32(bytes[14]) << 16) | (UInt32(bytes[15]) << 24))
|
||||
let current = Double(currentRaw) / 100.0
|
||||
|
||||
return (nonce, temp, hum, pres, voltage, current)
|
||||
}
|
||||
}
|
||||
|
||||
extension BLEManager: CBCentralManagerDelegate {
|
||||
func centralManagerDidUpdateState(_ central: CBCentralManager) {
|
||||
bluetoothState = central.state
|
||||
|
||||
switch central.state {
|
||||
case .poweredOn:
|
||||
print("Bluetooth is powered on")
|
||||
case .poweredOff:
|
||||
print("Bluetooth is powered off")
|
||||
isScanning = false
|
||||
case .unauthorized:
|
||||
print("Bluetooth is unauthorized")
|
||||
case .unsupported:
|
||||
print("Bluetooth is not supported")
|
||||
default:
|
||||
print("Bluetooth state: \(central.state.rawValue)")
|
||||
}
|
||||
}
|
||||
|
||||
func centralManager(_ central: CBCentralManager, didDiscover peripheral: CBPeripheral, advertisementData: [String: Any], rssi RSSI: NSNumber) {
|
||||
// Check device name
|
||||
guard let name = peripheral.name, name == deviceName else {
|
||||
return
|
||||
}
|
||||
|
||||
// Check for manufacturer data
|
||||
guard let manufacturerData = advertisementData[CBAdvertisementDataManufacturerDataKey] as? Data,
|
||||
manufacturerData.count >= 2 else {
|
||||
return
|
||||
}
|
||||
|
||||
// Extract company ID (first 2 bytes, little-endian)
|
||||
let companyIDBytes = manufacturerData.prefix(2)
|
||||
let extractedCompanyID = UInt16(companyIDBytes[0]) | (UInt16(companyIDBytes[1]) << 8)
|
||||
|
||||
guard extractedCompanyID == companyID else {
|
||||
return
|
||||
}
|
||||
|
||||
// Parse the payload (skip the first 2 bytes which are the company ID)
|
||||
let payload = manufacturerData.dropFirst(2)
|
||||
|
||||
guard let parsed = parseManufacturerData(payload) else {
|
||||
return
|
||||
}
|
||||
|
||||
// Create unique key for deduplication
|
||||
let key = "\(peripheral.identifier.uuidString)-\(parsed.nonce)"
|
||||
guard !seenNonces.contains(key) else {
|
||||
return
|
||||
}
|
||||
seenNonces.insert(key)
|
||||
|
||||
// Create reading
|
||||
let reading = SensorReading(
|
||||
timestamp: Date(),
|
||||
deviceAddress: peripheral.identifier.uuidString,
|
||||
nonce: parsed.nonce,
|
||||
temperature: parsed.temp,
|
||||
humidity: parsed.hum,
|
||||
pressure: parsed.pres,
|
||||
voltage: parsed.voltage,
|
||||
current: parsed.current,
|
||||
rssi: RSSI.intValue
|
||||
)
|
||||
|
||||
DispatchQueue.main.async {
|
||||
self.readings.insert(reading, at: 0)
|
||||
|
||||
// Keep only last 100 readings
|
||||
if self.readings.count > 100 {
|
||||
self.readings = Array(self.readings.prefix(100))
|
||||
}
|
||||
}
|
||||
|
||||
print("[\(reading.timestampString)] (\(String(format: "%04X", parsed.nonce))) \(peripheral.identifier.uuidString)")
|
||||
print(" T=\(String(format: "%5.1f", parsed.temp))°C H=\(String(format: "%5.1f", parsed.hum))% P=\(String(format: "%7.1f", parsed.pres))hPa")
|
||||
print(" V=\(String(format: "%5.2f", parsed.voltage))V I=\(String(format: "%7.2f", parsed.current))mA P=\(String(format: "%7.2f", reading.power))mW")
|
||||
print(" RSSI=\(String(format: "%3d", RSSI.intValue))dBm")
|
||||
print()
|
||||
}
|
||||
}
|
||||
230
EnvSensorReader/ContentView.swift
Normal file
230
EnvSensorReader/ContentView.swift
Normal file
@@ -0,0 +1,230 @@
|
||||
import SwiftUI
|
||||
|
||||
struct ContentView: View {
|
||||
@StateObject private var bleManager = BLEManager()
|
||||
|
||||
var body: some View {
|
||||
NavigationView {
|
||||
VStack {
|
||||
// Status bar
|
||||
HStack {
|
||||
Image(systemName: bluetoothIcon)
|
||||
.foregroundColor(bluetoothColor)
|
||||
Text(bluetoothStatusText)
|
||||
.font(.subheadline)
|
||||
Spacer()
|
||||
if bleManager.isScanning {
|
||||
ProgressView()
|
||||
.scaleEffect(0.8)
|
||||
Text("Scanning...")
|
||||
.font(.subheadline)
|
||||
.foregroundColor(.secondary)
|
||||
}
|
||||
}
|
||||
.padding()
|
||||
.background(Color(.systemGray6))
|
||||
|
||||
// Readings list
|
||||
if bleManager.readings.isEmpty {
|
||||
Spacer()
|
||||
VStack(spacing: 16) {
|
||||
Image(systemName: "antenna.radiowaves.left.and.right")
|
||||
.font(.system(size: 60))
|
||||
.foregroundColor(.secondary)
|
||||
Text("No readings yet")
|
||||
.font(.title2)
|
||||
.foregroundColor(.secondary)
|
||||
if !bleManager.isScanning && bleManager.bluetoothState == .poweredOn {
|
||||
Text("Tap Start to scan for EnvSensor devices")
|
||||
.font(.subheadline)
|
||||
.foregroundColor(.secondary)
|
||||
.multilineTextAlignment(.center)
|
||||
}
|
||||
}
|
||||
Spacer()
|
||||
} else {
|
||||
List(bleManager.readings) { reading in
|
||||
SensorReadingRow(reading: reading)
|
||||
}
|
||||
.listStyle(.plain)
|
||||
}
|
||||
|
||||
// Start/Stop button
|
||||
Button(action: toggleScanning) {
|
||||
Text(bleManager.isScanning ? "Stop Scanning" : "Start Scanning")
|
||||
.font(.headline)
|
||||
.foregroundColor(.white)
|
||||
.frame(maxWidth: .infinity)
|
||||
.padding()
|
||||
.background(bleManager.bluetoothState == .poweredOn ? Color.blue : Color.gray)
|
||||
.cornerRadius(10)
|
||||
}
|
||||
.padding()
|
||||
.disabled(bleManager.bluetoothState != .poweredOn)
|
||||
}
|
||||
.navigationTitle("EnvSensor Reader")
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
}
|
||||
}
|
||||
|
||||
private var bluetoothIcon: String {
|
||||
switch bleManager.bluetoothState {
|
||||
case .poweredOn:
|
||||
return "bluetooth"
|
||||
case .poweredOff:
|
||||
return "bluetooth.slash"
|
||||
default:
|
||||
return "bluetooth"
|
||||
}
|
||||
}
|
||||
|
||||
private var bluetoothColor: Color {
|
||||
bleManager.bluetoothState == .poweredOn ? .blue : .red
|
||||
}
|
||||
|
||||
private var bluetoothStatusText: String {
|
||||
switch bleManager.bluetoothState {
|
||||
case .poweredOn:
|
||||
return "Bluetooth Ready"
|
||||
case .poweredOff:
|
||||
return "Bluetooth Off"
|
||||
case .unauthorized:
|
||||
return "Bluetooth Unauthorized"
|
||||
case .unsupported:
|
||||
return "Bluetooth Not Supported"
|
||||
default:
|
||||
return "Bluetooth Unknown"
|
||||
}
|
||||
}
|
||||
|
||||
private func toggleScanning() {
|
||||
if bleManager.isScanning {
|
||||
bleManager.stopScanning()
|
||||
} else {
|
||||
bleManager.startScanning()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct SensorReadingRow: View {
|
||||
let reading: SensorReading
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
// Header
|
||||
HStack {
|
||||
Text(reading.timestampString)
|
||||
.font(.headline)
|
||||
Text("(\(String(format: "%04X", reading.nonce)))")
|
||||
.font(.subheadline)
|
||||
.foregroundColor(.secondary)
|
||||
Spacer()
|
||||
Text("\(reading.rssi) dBm")
|
||||
.font(.subheadline)
|
||||
.foregroundColor(rssiColor(reading.rssi))
|
||||
}
|
||||
|
||||
// Device address
|
||||
Text(reading.deviceAddress)
|
||||
.font(.caption)
|
||||
.foregroundColor(.secondary)
|
||||
|
||||
// Environmental readings
|
||||
HStack(spacing: 20) {
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
Text("Temperature")
|
||||
.font(.caption)
|
||||
.foregroundColor(.secondary)
|
||||
HStack(spacing: 4) {
|
||||
Image(systemName: "thermometer")
|
||||
.foregroundColor(.red)
|
||||
Text(String(format: "%.1f°C", reading.temperature))
|
||||
.font(.system(.body, design: .monospaced))
|
||||
}
|
||||
}
|
||||
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
Text("Humidity")
|
||||
.font(.caption)
|
||||
.foregroundColor(.secondary)
|
||||
HStack(spacing: 4) {
|
||||
Image(systemName: "humidity")
|
||||
.foregroundColor(.blue)
|
||||
Text(String(format: "%.1f%%", reading.humidity))
|
||||
.font(.system(.body, design: .monospaced))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
HStack(spacing: 20) {
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
Text("Pressure")
|
||||
.font(.caption)
|
||||
.foregroundColor(.secondary)
|
||||
HStack(spacing: 4) {
|
||||
Image(systemName: "gauge")
|
||||
.foregroundColor(.purple)
|
||||
Text(String(format: "%.1f hPa", reading.pressure))
|
||||
.font(.system(.body, design: .monospaced))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Divider()
|
||||
|
||||
// Power readings
|
||||
HStack(spacing: 20) {
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
Text("Voltage")
|
||||
.font(.caption)
|
||||
.foregroundColor(.secondary)
|
||||
HStack(spacing: 4) {
|
||||
Image(systemName: "bolt")
|
||||
.foregroundColor(.orange)
|
||||
Text(String(format: "%.2f V", reading.voltage))
|
||||
.font(.system(.body, design: .monospaced))
|
||||
}
|
||||
}
|
||||
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
Text("Current")
|
||||
.font(.caption)
|
||||
.foregroundColor(.secondary)
|
||||
HStack(spacing: 4) {
|
||||
Image(systemName: "waveform.path")
|
||||
.foregroundColor(.green)
|
||||
Text(String(format: "%.2f mA", reading.current))
|
||||
.font(.system(.body, design: .monospaced))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
HStack(spacing: 4) {
|
||||
Image(systemName: "power")
|
||||
.foregroundColor(.yellow)
|
||||
Text("Power: ")
|
||||
.font(.caption)
|
||||
.foregroundColor(.secondary)
|
||||
Text(String(format: "%.2f mW", reading.power))
|
||||
.font(.system(.body, design: .monospaced))
|
||||
}
|
||||
}
|
||||
.padding(.vertical, 8)
|
||||
}
|
||||
|
||||
private func rssiColor(_ rssi: Int) -> Color {
|
||||
if rssi > -60 {
|
||||
return .green
|
||||
} else if rssi > -80 {
|
||||
return .orange
|
||||
} else {
|
||||
return .red
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct ContentView_Previews: PreviewProvider {
|
||||
static var previews: some View {
|
||||
ContentView()
|
||||
}
|
||||
}
|
||||
10
EnvSensorReader/EnvSensorReaderApp.swift
Normal file
10
EnvSensorReader/EnvSensorReaderApp.swift
Normal file
@@ -0,0 +1,10 @@
|
||||
import SwiftUI
|
||||
|
||||
@main
|
||||
struct EnvSensorReaderApp: App {
|
||||
var body: some Scene {
|
||||
WindowGroup {
|
||||
ContentView()
|
||||
}
|
||||
}
|
||||
}
|
||||
55
EnvSensorReader/Info.plist
Normal file
55
EnvSensorReader/Info.plist
Normal file
@@ -0,0 +1,55 @@
|
||||
<?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>CFBundleDevelopmentRegion</key>
|
||||
<string>$(DEVELOPMENT_LANGUAGE)</string>
|
||||
<key>CFBundleExecutable</key>
|
||||
<string>$(EXECUTABLE_NAME)</string>
|
||||
<key>CFBundleIdentifier</key>
|
||||
<string>$(PRODUCT_BUNDLE_IDENTIFIER)</string>
|
||||
<key>CFBundleInfoDictionaryVersion</key>
|
||||
<string>6.0</string>
|
||||
<key>CFBundleName</key>
|
||||
<string>$(PRODUCT_NAME)</string>
|
||||
<key>CFBundlePackageType</key>
|
||||
<string>$(PRODUCT_BUNDLE_PACKAGE_TYPE)</string>
|
||||
<key>CFBundleShortVersionString</key>
|
||||
<string>1.0</string>
|
||||
<key>CFBundleVersion</key>
|
||||
<string>1</string>
|
||||
<key>LSRequiresIPhoneOS</key>
|
||||
<true/>
|
||||
<key>UIApplicationSceneManifest</key>
|
||||
<dict>
|
||||
<key>UIApplicationSupportsMultipleScenes</key>
|
||||
<true/>
|
||||
</dict>
|
||||
<key>UIApplicationSupportsIndirectInputEvents</key>
|
||||
<true/>
|
||||
<key>UILaunchScreen</key>
|
||||
<dict/>
|
||||
<key>UIRequiredDeviceCapabilities</key>
|
||||
<array>
|
||||
<string>armv7</string>
|
||||
<string>bluetooth-le</string>
|
||||
</array>
|
||||
<key>UISupportedInterfaceOrientations</key>
|
||||
<array>
|
||||
<string>UIInterfaceOrientationPortrait</string>
|
||||
<string>UIInterfaceOrientationLandscapeLeft</string>
|
||||
<string>UIInterfaceOrientationLandscapeRight</string>
|
||||
</array>
|
||||
<key>UISupportedInterfaceOrientations~ipad</key>
|
||||
<array>
|
||||
<string>UIInterfaceOrientationPortrait</string>
|
||||
<string>UIInterfaceOrientationPortraitUpsideDown</string>
|
||||
<string>UIInterfaceOrientationLandscapeLeft</string>
|
||||
<string>UIInterfaceOrientationLandscapeRight</string>
|
||||
</array>
|
||||
<key>NSBluetoothAlwaysUsageDescription</key>
|
||||
<string>This app needs Bluetooth access to scan for EnvSensor devices and read environmental data.</string>
|
||||
<key>NSBluetoothPeripheralUsageDescription</key>
|
||||
<string>This app needs Bluetooth access to scan for EnvSensor devices and read environmental data.</string>
|
||||
</dict>
|
||||
</plist>
|
||||
28
EnvSensorReader/SensorReading.swift
Normal file
28
EnvSensorReader/SensorReading.swift
Normal file
@@ -0,0 +1,28 @@
|
||||
import Foundation
|
||||
|
||||
struct SensorReading: Identifiable, Equatable {
|
||||
let id = UUID()
|
||||
let timestamp: Date
|
||||
let deviceAddress: String
|
||||
let nonce: UInt16
|
||||
let temperature: Double
|
||||
let humidity: Double
|
||||
let pressure: Double
|
||||
let voltage: Double
|
||||
let current: Double
|
||||
let rssi: Int
|
||||
|
||||
var power: Double {
|
||||
voltage * current
|
||||
}
|
||||
|
||||
var timestampString: String {
|
||||
let formatter = DateFormatter()
|
||||
formatter.dateFormat = "HH:mm:ss"
|
||||
return formatter.string(from: timestamp)
|
||||
}
|
||||
|
||||
static func == (lhs: SensorReading, rhs: SensorReading) -> Bool {
|
||||
lhs.deviceAddress == rhs.deviceAddress && lhs.nonce == rhs.nonce
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user