Skip to content
38 changes: 32 additions & 6 deletions FindMyCat/Bluetooth/BluetoothLECentral.swift
Original file line number Diff line number Diff line change
Expand Up @@ -119,7 +119,7 @@ class BLEDataCommunicationChannel: NSObject {
logger.info("Scanning stopped.")
}

// Clear peripherals in qorvoDevices[] if not responding for more than one second
// Clear peripherals in qorvoDevices[] if not responding for more than 10 seconds
@objc func timerHandler() {
var index = 0

Expand All @@ -129,8 +129,8 @@ class BLEDataCommunicationChannel: NSObject {
// Get current timestamp
let timeStamp = Int64((Date().timeIntervalSince1970 * 1000.0).rounded())

// Remove device if timestamp is bigger than 5000 msec
if timeStamp > (preciseFindableDevice!.bleTimestamp + 5000) {
// Remove device if timestamp is bigger than 10000 msec
if timeStamp > (preciseFindableDevice!.bleTimestamp + 10000) {
let deviceID = preciseFindableDevice?.bleUniqueID

logger.info("Device \(preciseFindableDevice?.blePeripheralName ?? "Unknown") timed-out removed at index \(index)")
Expand Down Expand Up @@ -171,6 +171,17 @@ class BLEDataCommunicationChannel: NSObject {
centralManager.stopScan()
}

func pauseScanning() {
centralManager.stopScan()
logger.info("Scanning paused for active NI session.")
}

func resumeScanning() {
if centralManager.state == .poweredOn {
startScan()
}
}

func connectPeripheral(_ uniqueID: Int) throws {

if let deviceToConnect = getDeviceFromUniqueID(uniqueID) {
Expand Down Expand Up @@ -204,7 +215,9 @@ class BLEDataCommunicationChannel: NSObject {
}

func requestRSSI(_ uniqueID: Int) {
getDeviceFromUniqueID(uniqueID)?.blePeripheral.readRSSI()
guard let peripheral = getDeviceFromUniqueID(uniqueID)?.blePeripheral,
peripheral.state == .connected else { return }
peripheral.readRSSI()
}

func sendData(_ data: Data, _ uniqueID: Int) throws {
Expand Down Expand Up @@ -338,21 +351,34 @@ extension BLEDataCommunicationChannel: CBCentralManagerDelegate {

let timeStamp = Int64((Date().timeIntervalSince1970 * 1000.0).rounded())

guard let manuf = advertisementData[CBAdvertisementDataManufacturerDataKey] as? Data,
manuf.count >= Constants.FMCAdvManufPrefix.count,
Array(manuf.prefix(Constants.FMCAdvManufPrefix.count)) == Constants.FMCAdvManufPrefix else {
return
}

let advertisedName = (advertisementData[CBAdvertisementDataLocalNameKey] as? String) ?? peripheral.name

// Check if peripheral is already discovered
if let preciseFindableDevice = getDeviceFromUniqueID(peripheral.hashValue) {

// if yes, update the timestamp and RSSI
preciseFindableDevice.bleTimestamp = timeStamp
preciseFindableDevice.bleRSSI = RSSI.intValue

// sticky-update name: once we see a real name, keep it; never overwrite with empty
if let newName = advertisedName, !newName.isEmpty,
preciseFindableDevice.blePeripheralName.isEmpty || preciseFindableDevice.blePeripheralName == "Unknown" {
preciseFindableDevice.blePeripheralName = newName
}

return
}

// If not discovered, include peripheral to preciseFindableDevices
let name = advertisementData[CBAdvertisementDataLocalNameKey] as? String
preciseFindableDevices.append(PreciseFindableDevice(peripheral: peripheral,
uniqueID: peripheral.hashValue,
peripheralName: name ?? "Unknown",
peripheralName: advertisedName ?? "Unknown",
timeStamp: timeStamp,
rssi: RSSI.intValue))

Expand Down
3 changes: 3 additions & 0 deletions FindMyCat/Constants.swift
Original file line number Diff line number Diff line change
Expand Up @@ -20,4 +20,7 @@ struct Constants {

// Confirmation Messages
static let RemoveDeviceConfirmationMessage = "Are you sure you want to remove the device from your account?"

// BLE advertising filter (must match embedded fmc_adv_magic.h)
static let FMCAdvManufPrefix: [UInt8] = [0xFF, 0xFF, 0x46, 0x4D, 0x43]
}
Original file line number Diff line number Diff line change
Expand Up @@ -151,6 +151,8 @@ class AddEditDeviceViewController: UIViewController, UITextFieldDelegate {
uniqueIdReadOnlyTextField.cornerRadius = 11

uniqueIdReadOnlyTextField.returnKeyType = .done
uniqueIdReadOnlyTextField.isUserInteractionEnabled = false
uniqueIdReadOnlyTextField.textColor = .systemGray

sheetView.addSubview(uniqueIdReadOnlyTextField)

Expand Down
11 changes: 11 additions & 0 deletions FindMyCat/Controllers/DeviceBottomDrawerViewController.swift
Original file line number Diff line number Diff line change
Expand Up @@ -529,6 +529,17 @@ extension DeviceBottomDrawerController: DeviceCellDelegate {
parentVc.present(vc, animated: true)
}

func playSound() {
guard selectedDeviceIndex != nil else { return }
let selectedDevice = SharedData.getDevices()[selectedDeviceIndex!]
guard let selectedDeviceUniqueBLEId = Int(selectedDevice.uniqueId) else { return }
do {
try BLEDataCommunicationChannel.shared.sendData(Data([MessageId.playSound.rawValue]), selectedDeviceUniqueBLEId)
} catch {
logger.error("Failed to send playSound: \(error.localizedDescription)")
}
}

func activateLostMode(currentMode: String) {

updateProgressOnLocationFetchButtonTimer()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,16 @@ class PreciseFinderViewContoller: UIViewController {
internal var NIAlgorithmHasConverged = false
internal var isUWBDistanceAvailable = false
internal var lastUWBDistanceTimestamp: Date?
internal var connectRetriesCancelled = false

internal let proximityHapticMedium = UIImpactFeedbackGenerator(style: .medium)
internal let proximityHapticHeavy = UIImpactFeedbackGenerator(style: .heavy)
internal var lastProximityHapticAt: Date?
internal let proximityHapticThresholdFt: Float = 6.0
internal let proximityHapticHeavyBelowFt: Float = 3.0
internal let proximityHapticFloorFt: Float = 0.5
internal let proximityHapticIntervalFar: TimeInterval = 0.6
internal let proximityHapticIntervalNear: TimeInterval = 0.05

// MARK: - Util managers
let uwbUtilManager = UWBUtils()
Expand All @@ -73,6 +83,8 @@ class PreciseFinderViewContoller: UIViewController {

configureDataChannel()

BLEDataCommunicationChannel.shared.pauseScanning()

bleReadoutTimer = Timer.scheduledTimer(timeInterval: 0.5, target: self, selector: #selector(refreshBLEReadout), userInfo: nil, repeats: true)
}

Expand All @@ -86,6 +98,8 @@ class PreciseFinderViewContoller: UIViewController {

// Pause the ARSession before it gets deallocated
pauseARSession()

BLEDataCommunicationChannel.shared.resumeScanning()
}

// MARK: - Setup subviews
Expand Down Expand Up @@ -259,12 +273,18 @@ class PreciseFinderViewContoller: UIViewController {
soundButton.tintColor = viewLayerColor
soundButton.translatesAutoresizingMaskIntoConstraints = false

soundButton.addTarget(self, action: #selector(soundButtonPressed), for: .touchUpInside)

NSLayoutConstraint.activate([
soundButton.bottomAnchor.constraint(equalTo: view.bottomAnchor, constant: -30),
soundButton.trailingAnchor.constraint(equalTo: view.trailingAnchor, constant: -30)
])
}

@objc private func soundButtonPressed() {
sendDataToAccessory(Data([MessageId.playSound.rawValue]), deviceUniqueBLEId)
}

func setupDistanceLabel() {
view.addSubview(distanceLabel)

Expand Down Expand Up @@ -307,7 +327,14 @@ class PreciseFinderViewContoller: UIViewController {
}

@objc private func cancelButtonPressed() {
// cleanup -- stop the data channel and disconnect from Device.
connectRetriesCancelled = true
sendDataToAccessory(Data([MessageId.stop.rawValue]), deviceUniqueBLEId)

for (_, session) in referenceDict {
session.invalidate()
}
referenceDict.removeAll()

deinitDataCommunicationChannel()
disconnectFromAccessory(deviceUniqueBLEId)
dismiss(animated: true, completion: nil)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,8 @@ extension PreciseFinderViewContoller {
}

internal func accessoryConnected(deviceID: Int) {
BLEDataCommunicationChannel.shared.pauseScanning()

// Create a NISession for the new device
referenceDict[deviceID] = NISession()
referenceDict[deviceID]?.delegate = self
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -27,14 +27,16 @@ extension PreciseFinderViewContoller {
var retries = 0

func connectWithDelay() {
if connectRetriesCancelled { return }
do {
try BLEDataCommunicationChannel.shared.connectPeripheral(deviceID)
} catch {
logger.error("Failed to connect to accessory: \(error)")
BLEDataCommunicationChannel.shared.resumeScanning()
retries += 1
// Retry after delay
if retries < maxRetries {
DispatchQueue.main.asyncAfter(deadline: .now() + retryDelay) {
DispatchQueue.main.asyncAfter(deadline: .now() + retryDelay) { [weak self] in
guard let self = self, !self.connectRetriesCancelled else { return }
connectWithDelay()
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -110,7 +110,22 @@ extension PreciseFinderViewContoller: NISessionDelegate {

isUWBDistanceAvailable = true
lastUWBDistanceTimestamp = Date()
distanceLabel.text = String(format: "%.1f ft", convertMetersToFeet(meters: distance!))
let distanceFt = convertMetersToFeet(meters: distance!)
distanceLabel.text = String(format: "%.1f ft", distanceFt)

if distanceFt < proximityHapticThresholdFt {
let clamped = max(proximityHapticFloorFt, min(distanceFt, proximityHapticThresholdFt))
let tLinear = TimeInterval((clamped - proximityHapticFloorFt) / (proximityHapticThresholdFt - proximityHapticFloorFt))
let tCurved = log(1 + 9 * tLinear) / log(10)
let interval = proximityHapticIntervalNear + (proximityHapticIntervalFar - proximityHapticIntervalNear) * tCurved
let now = Date()
if lastProximityHapticAt == nil || now.timeIntervalSince(lastProximityHapticAt!) >= interval {
let generator = distanceFt <= proximityHapticHeavyBelowFt ? proximityHapticHeavy : proximityHapticMedium
generator.impactOccurred()
generator.prepare()
lastProximityHapticAt = now
}
}

// Update arrow
let radians: CGFloat = CGFloat(azimuth) * (.pi / 180)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -82,10 +82,11 @@ class ScanDevicesViewController: UIViewController {
let device = ScannedDeviceView(frame: CGRect(x: 0, y: 0, width: circleSize, height: circleSize))

if let bleUniqueID = scannedDevice?.bleUniqueID {
device.numberLabel.text = String(bleUniqueID)
let name = scannedDevice?.blePeripheralName ?? ""
device.numberLabel.text = name.isEmpty ? "Unknown device" : name
device.tag = bleUniqueID
} else {
device.numberLabel.text = "unknown"
device.numberLabel.text = "Unknown device"
}

let tapGesture = UITapGestureRecognizer(target: self, action: #selector(handleTap))
Expand Down
4 changes: 2 additions & 2 deletions FindMyCat/Models/Devices.swift
Original file line number Diff line number Diff line change
Expand Up @@ -7,9 +7,9 @@

import Foundation

class Device: Decodable {
class Device: Codable {

struct Attributes: Decodable {
struct Attributes: Codable {
var emoji: String?
}

Expand Down
23 changes: 23 additions & 0 deletions FindMyCat/Models/SharedData.swift
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,8 @@ class SharedData {
private let webSocketManager = WebSocketManager()
private var cancellables = Set<AnyCancellable>()

private static let localDevicesKey = "LocalDebugDevices"

// MARK: Initialize function
private init() {
// Initialize WebSocket connection and handle incoming data updates
Expand All @@ -38,6 +40,7 @@ class SharedData {

// Sequentially exeute API calls and finally register websockets.
fetchDevicesFromRestAPI {
SharedData.mergeInPersistedLocalDevices()
self.fetchPositionsFromRestAPI {
self.configureWebsocket {
// Fetched complete.
Expand All @@ -62,6 +65,26 @@ class SharedData {

public static func addLocalDevice(_ device: Device) {
devices.append(device)
persistLocalDevices()
}

private static func loadPersistedLocalDevices() -> [Device] {
guard let data = UserDefaults.standard.data(forKey: localDevicesKey) else { return [] }
return (try? JSONDecoder().decode([Device].self, from: data)) ?? []
}

private static func persistLocalDevices() {
let locals = devices.filter { $0.id == 0 }
if let data = try? JSONEncoder().encode(locals) {
UserDefaults.standard.set(data, forKey: localDevicesKey)
}
}

private static func mergeInPersistedLocalDevices() {
let locals = loadPersistedLocalDevices()
for local in locals where !devices.contains(where: { $0.uniqueId == local.uniqueId }) {
devices.append(local)
}
}

public static func getDevicesCount() -> Int {
Expand Down
7 changes: 7 additions & 0 deletions FindMyCat/Views/DeviceTableViewCell.swift
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ class Button: UIButton {
protocol DeviceCellDelegate: AnyObject {
func launchPreciseFindScreen()
func activateLostMode(currentMode: String)
func playSound()
}

class ButtonWithProgressBar: UIButton {
Expand Down Expand Up @@ -247,12 +248,18 @@ class DeviceTableViewCell: UITableViewCell {

soundButton.translatesAutoresizingMaskIntoConstraints = false

soundButton.addTarget(self, action: #selector(self.playSoundTapped), for: .touchUpInside)

NSLayoutConstraint.activate([
soundButton.leadingAnchor.constraint(equalTo: findButton.trailingAnchor, constant: 16),
soundButton.bottomAnchor.constraint(equalTo: findButton.bottomAnchor)
])
}

@objc private func playSoundTapped() {
delegate?.playSound()
}

private func configureFindButton() {
findButton.tintColor = UIColor(cgColor: grayColor)

Expand Down
24 changes: 23 additions & 1 deletion FindMyCat/Views/ScanDevices/ScanningAnimationView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,9 @@ class ScanningAnimationView: UIView {
let strokeColor = UIColor.gray

private var animationTimer: Timer?
private let hapticGenerator = UIImpactFeedbackGenerator(style: .heavy)
private let pulseDuration: CFTimeInterval = 0.7
private let hapticEveryNPulses = 2

override init(frame: CGRect) {
super.init(frame: frame)
Expand Down Expand Up @@ -49,6 +52,7 @@ class ScanningAnimationView: UIView {
}

func startAnimation() {

let centerX = bounds.width / 2
let centerY = (4 * bounds.height) / 5
let spread = 100
Expand Down Expand Up @@ -84,10 +88,28 @@ class ScanningAnimationView: UIView {
let animation = CAAnimationGroup()
animation.animations = [scaleAnim, opacityAnim]
animation.timingFunction = CAMediaTimingFunction(name: CAMediaTimingFunctionName.easeOut)
animation.duration = CFTimeInterval(1)
animation.duration = pulseDuration

animation.repeatCount = .infinity
rippleShape.add(animation, forKey: "rippleEffect")

hapticGenerator.prepare()
animationTimer?.invalidate()
self.hapticGenerator.impactOccurred()
self.hapticGenerator.prepare()
animationTimer = Timer.scheduledTimer(withTimeInterval: pulseDuration * Double(hapticEveryNPulses), repeats: true) { [weak self] _ in
self?.hapticGenerator.impactOccurred()
self?.hapticGenerator.prepare()
}
}

func stopAnimation() {
animationTimer?.invalidate()
animationTimer = nil
}

deinit {
animationTimer?.invalidate()
}
}

Expand Down