Skip to content
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,8 @@ final class ExperimentGPSInput: NSObject, CLLocationManagerDelegate {

private var queue: DispatchQueue?

var onAuthorizationChange: ((CLAuthorizationStatus) -> Void)?

init (latBuffer: DataBuffer?, lonBuffer: DataBuffer?, zBuffer: DataBuffer?, zWgs84Buffer: DataBuffer?, vBuffer: DataBuffer?, dirBuffer: DataBuffer?, accuracyBuffer: DataBuffer?, zAccuracyBuffer: DataBuffer?, tBuffer: DataBuffer?, statusBuffer: DataBuffer?, satellitesBuffer: DataBuffer?, timeReference: ExperimentTimeReference) {

self.latBuffer = latBuffer
Expand Down Expand Up @@ -81,6 +83,16 @@ final class ExperimentGPSInput: NSObject, CLLocationManagerDelegate {
}
}

@available(iOS 14.0, *)
func locationManagerDidChangeAuthorization(_ manager: CLLocationManager) {
onAuthorizationChange?(manager.authorizationStatus)
}

// For iOS 13 and older
func locationManager(_ manager: CLLocationManager, didChangeAuthorization status: CLAuthorizationStatus) {
onAuthorizationChange?(status)
}

func clear() {

}
Expand Down
266 changes: 139 additions & 127 deletions phyphox-iOS/phyphox/Experiments/Experiment.swift

Large diffs are not rendered by default.

319 changes: 319 additions & 0 deletions phyphox-iOS/phyphox/Experiments/FlashlightOutput.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,319 @@
import AVFoundation
import Foundation

/// A wrapper to manage flashlight output.
/// It coordinates different control modes (Intensity vs Strobe) and handles hardware safety.
class FlashlightOutput {
private let manager = FlashlightManager()
private var controllers: [FlashlightController] = []

var onThermalWarning: ((ProcessInfo.ThermalState) -> Void)?
private(set) var isOverheated: Bool = false

init() {
NotificationCenter.default.addObserver(
self,
selector: #selector(thermalStateDidChange),
name: ProcessInfo.thermalStateDidChangeNotification,
object: nil
)
checkThermalState()
}

deinit {
NotificationCenter.default.removeObserver(self)
}

func start() {
guard !isOverheated else {
onThermalWarning?(ProcessInfo.processInfo.thermalState)
return
}

controllers.forEach { $0.start() }
}

func stop() {
controllers.forEach { $0.stop() }
}

@objc private func thermalStateDidChange() {
checkThermalState()
}

private func checkThermalState() {
let state = ProcessInfo.processInfo.thermalState

if state == .serious || state == .critical {
isOverheated = true
manager.isOverheated = true
stop()

DispatchQueue.main.async { [weak self] in
self?.onThermalWarning?(state)
}
} else {
isOverheated = false
manager.isOverheated = false
}
}

func attachController(_ controller: FlashlightController) {
controllers.append(controller)
}

public func getInternalManager() -> FlashlightManager {
return manager
}

public func hasStrobeController() -> Bool {
return controllers.contains{ $0 is StrobeController}
}

func isStrobeActiveWithFrequency() -> Bool {
guard let strobe = controllers.first(where: { $0 is StrobeController }) as? StrobeController else {
return false
}

return strobe.getCurrentFrequency() > 0
}

func isStrobeUsingBuffer() -> Bool {
guard let strobe = controllers.first(where: { $0 is StrobeController }) as? StrobeController else {
return false
}
return strobe.isBufferSource()
}

protocol FlashlightController {
func start()
func stop()
var isActive: Bool { get }
}

// MARK: - Strobe Controller
class StrobeController: FlashlightController {
private let manager: FlashlightManager
private let parameter: FlashlightParameter
private(set) var isActive: Bool = false
private var lastFrequency: Double = -1.0

init(manager: FlashlightManager, parameter: FlashlightParameter) {
self.manager = manager
self.parameter = parameter
}

func getCurrentFrequency() -> Double { return parameter.getValue() ?? 0.0 }

func isBufferSource() -> Bool { return parameter.isBuffer }

func start() {
let currentFreq = parameter.getValue() ?? 0.0

if !isActive {
isActive = true
lastFrequency = currentFreq
// Passes a closure to the manager so the background thread can fetch fresh data
manager.startStrobe { [weak self] in
return self?.parameter.getValue() ?? 0.0
}
} else if abs(currentFreq - lastFrequency) > 0.0001 {
// If freq changed while running, interrupt the loop's 'sleep' to apply new rate immediately.
lastFrequency = currentFreq
manager.pokeLoop()
}
}

func stop() {
guard isActive else { return }
isActive = false
lastFrequency = -1.0
manager.stopStrobe()
}
}

// MARK: - Intensity Controller
class IntensityController: FlashlightController {
private let manager: FlashlightManager
private let parameter: FlashlightParameter
private(set) var isActive: Bool = false
private var lastValue: Float = -1.0

init(manager: FlashlightManager, parameter: FlashlightParameter) {
self.manager = manager
self.parameter = parameter
}

func start() {
isActive = true
let val = Float(parameter.getValue() ?? 1.0)

if abs(val - lastValue) > 0.001 {
lastValue = val
manager.setIntensity(val)
}
}

func stop() {
isActive = false
lastValue = -1.0
manager.turnOff()
}
}
}

// MARK: - Flashlight Manager Engine

/// The manager responsible for thread safety and direct interaction with AVCaptureDevice.
class FlashlightManager {
private let device = AVCaptureDevice.default(for: .video)

private let hardwareQueue = DispatchQueue(label: "de.rwth.flashlight.hardware", qos: .userInteractive)
/// Used to manage the strobe timing and allow immediate interruption of "sleep" states.
private let strobeCondition = NSCondition()

private var isStrobeActive = false
private var currentIntensity: Float = 1.0
private var frequencyProvider: (() -> Double)?

// Hardware state cache used to guard the redudent calls
private var lastAppliedLevel: Float = -1.0
private var lastAppliedOn: Bool = false

var isOverheated: Bool = false

func setIntensity(_ level: Float) {
hardwareQueue.async { [weak self] in
guard let self = self else { return }
self.currentIntensity = level
self.pokeLoop() // Ensure the loop reacts to brightness changes instantly

if !self.isStrobeActive {
self.applyTorch(on: level > 0, level: level)
}
}
}

/// Forces the background strobe thread to wake up from its current wait interval.
func pokeLoop() {
strobeCondition.lock()
strobeCondition.broadcast() // Wakes any thread currently at strobeCondition.wait()
strobeCondition.unlock()
}

/// Initializes and starts the background strobe thread.
func startStrobe(provider: @escaping () -> Double) {
hardwareQueue.async { [weak self] in
guard let self = self else { return }
self.frequencyProvider = provider

if !self.isStrobeActive {
self.isStrobeActive = true
self.pokeLoop()
DispatchQueue.global(qos: .userInteractive).async { [weak self] in
self?.runStrobeLoop()
}
}
}
}

/// The core logic loop running on a background thread.
private func runStrobeLoop() {
// Keeps track of which phase we are in so rapid changes don't "restart" the pulse
var isCurrentlyInOnPhase = false

while true {
strobeCondition.lock()

if !self.isStrobeActive {
self.applyTorchInQueue(on: false, level: 0)
strobeCondition.unlock()
break
}

let freq = self.frequencyProvider?() ?? 0
let level = self.currentIntensity

// 1. STEADY MODE
if freq <= 0 {
isCurrentlyInOnPhase = true
self.applyTorchInQueue(on: level > 0, level: level)
strobeCondition.wait(until: Date().addingTimeInterval(0.5))
strobeCondition.unlock()
continue
}

// 2. STROBE MODE
let interval = 1.0 / freq
let halfInterval = interval / 2.0

// Toggle Phase
isCurrentlyInOnPhase = !isCurrentlyInOnPhase

if isCurrentlyInOnPhase {
self.applyTorchInQueue(on: level > 0, level: level)
} else {
self.applyTorchInQueue(on: false, level: 0)
}

// Wait for half the interval.
// If pokeLoop() is called, this returns early and we immediately recalculate
// the NEXT phase using the NEW frequency.
strobeCondition.wait(until: Date().addingTimeInterval(halfInterval))

strobeCondition.unlock()
}
}

func stopStrobe() {
hardwareQueue.async { [weak self] in
self?.isStrobeActive = false
self?.pokeLoop()
}
}

func turnOff() {
stopStrobe()
hardwareQueue.async { [weak self] in
self?.applyTorch(on: false, level: 0)
}
}

/// Ensures that strobe-thread requests are passed through the serial hardwareQueue.
private func applyTorchInQueue(on: Bool, level: Float) {
hardwareQueue.sync {
self.applyTorch(on: on, level: level)
}
}

/// The only point in the code that talks to AVCaptureDevice.
private func applyTorch(on: Bool, level: Float) {
guard let device = device, device.hasTorch, device.isTorchAvailable else { return }

if isOverheated { return }

let safeLevel = max(0.01, min(level, 1.0))
let targetOn = on && level > 0

// REDUNDANCY GUARD:
// Comparing current request with last applied hardware state.
// This prevents the "Lag" caused by spamming hardware locks.
if targetOn == lastAppliedOn && abs(safeLevel - lastAppliedLevel) < 0.001 && targetOn == true { return }
if targetOn == false && lastAppliedOn == false { return }

do {
try device.lockForConfiguration()
if targetOn {
try device.setTorchModeOn(level: safeLevel)
lastAppliedLevel = safeLevel
lastAppliedOn = true
} else {
device.torchMode = .off
lastAppliedOn = false
}
device.unlockForConfiguration()
} catch {
// If the hardware is locked by another process, skip this frame to keep loop timing consistent
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
//
// ExperimertFlashlightOutput.swift
// phyphox
//
// Created by Gaurav Tripathee on 23.03.26.
// Copyright © 2026 RWTH Aachen. All rights reserved.
//

enum FlashlightParameter: Equatable {
case buffer(buffer: DataBuffer)
case value(value: Double?)

func getValue() -> Double? {
switch self {
case .buffer(buffer: let buffer):
return buffer.last
case .value(value: let value):
return value
}
}

var isBuffer: Bool {
switch self {
case .buffer(buffer: _):
return true
case .value(value: _):
return false
}
}
}

class ExperimentFlashlightOutput {
let intensity: FlashlightParameter
let frequency: FlashlightParameter

init(intensity: FlashlightParameter, frequency: FlashlightParameter) {
self.intensity = intensity
self.frequency = frequency
}
}

Loading