This commit is contained in:
2025-11-27 17:42:54 +09:00
commit f38bee223c
13 changed files with 1200 additions and 0 deletions

View File

@@ -0,0 +1,11 @@
{
"colors" : [
{
"idiom" : "universal"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}

View File

@@ -0,0 +1,13 @@
{
"images" : [
{
"idiom" : "universal",
"platform" : "ios",
"size" : "1024x1024"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}

View File

@@ -0,0 +1,6 @@
{
"info" : {
"author" : "xcode",
"version" : 1
}
}

View 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()
}
}

View 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()
}
}

View File

@@ -0,0 +1,10 @@
import SwiftUI
@main
struct EnvSensorReaderApp: App {
var body: some Scene {
WindowGroup {
ContentView()
}
}
}

View 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>

View 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
}
}