mirror of
https://github.com/pion/mediadevices.git
synced 2026-04-22 15:57:27 +08:00
Add darwin runtime device observer support (#670)
* wip * wip * Organize * Remove unnecessary change in camera_darwin.go filtering * wip * Make observer stop safe during startup * wip IsAvailable impl * Fix non-darwin builds * Lock bg loop to main thread and add comment * Remove fmt prints * Simplify isAvailable; Add timeout for Read darwin * Match comment with code * Change to singleton pattern; Add clearer safer state machine states; Change language from Stop to Destroy; Add new error for when observer is unavailable; * Add stubs for linux * Move cancel() up so its not dead code sometimes * Add stubs for Windows too * Remove StopObserver usage * Add camera tests * Add device observer tests * Fix multiple destroy calls bug; Call setup in start * Improve isAvailable * Improve string handling in device observer c * Add error handling in example * Add comment about setup vs start * Rename and organize device observer darwin * Explicitly case initial state for setup * Fix potential destroy goroutine leak; Use only modern build tag; Return err not nil for stubs; Improve comments * Close startDone channel on device observer stop not wait
This commit is contained in:
@@ -0,0 +1,70 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"fmt"
|
||||
"os"
|
||||
"strings"
|
||||
|
||||
"github.com/pion/mediadevices/pkg/driver"
|
||||
"github.com/pion/mediadevices/pkg/driver/camera"
|
||||
)
|
||||
|
||||
func main() {
|
||||
fmt.Println("This example demonstrates query-based camera device discovery on Darwin.")
|
||||
fmt.Println("The background observer automatically updates the manager's device list")
|
||||
fmt.Println("when cameras are connected or disconnected.")
|
||||
|
||||
// Calling StartObserver without calling SetupObserver prior implicitly calls SetupObserver
|
||||
// due to state machine internals. We make SetupObserver and StartObserver distinct because
|
||||
// not all downstream programs will want to start pumping the NSRunLoop to handle events immediately.
|
||||
err := camera.StartObserver()
|
||||
if err != nil {
|
||||
fmt.Printf("failed to start observer: %v\n", err)
|
||||
}
|
||||
defer func() {
|
||||
err := camera.DestroyObserver()
|
||||
if err != nil {
|
||||
fmt.Printf("failed to destroy observer: %v\n", err)
|
||||
}
|
||||
}()
|
||||
|
||||
scanner := bufio.NewScanner(os.Stdin)
|
||||
queryCount := 0
|
||||
|
||||
queryDevices(0)
|
||||
|
||||
for {
|
||||
fmt.Print("\nPress Enter to query (or 'q' to exit): ")
|
||||
|
||||
if !scanner.Scan() {
|
||||
break
|
||||
}
|
||||
|
||||
input := strings.TrimSpace(scanner.Text())
|
||||
if strings.ToLower(input) == "q" {
|
||||
break
|
||||
}
|
||||
|
||||
queryCount++
|
||||
queryDevices(queryCount)
|
||||
}
|
||||
}
|
||||
|
||||
func queryDevices(count int) {
|
||||
if count > 0 {
|
||||
fmt.Printf("Query #%d\n", count)
|
||||
}
|
||||
|
||||
devices := driver.GetManager().Query(driver.FilterVideoRecorder())
|
||||
|
||||
if len(devices) == 0 {
|
||||
fmt.Println("No video devices found.")
|
||||
} else {
|
||||
fmt.Printf("Found %d video device(s):\n", len(devices))
|
||||
for i, d := range devices {
|
||||
info := d.Info()
|
||||
fmt.Printf(" %d. %s [%s]\n", i+1, info.Name, info.Label)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -30,6 +30,12 @@
|
||||
#import "AVFoundationBind.h"
|
||||
#include <string.h>
|
||||
|
||||
// AVFoundationBind.m is the entry point for cgo compilation (included by avfoundation_darwin.go).
|
||||
// Including DeviceObserver.m here compiles both into a single compilation unit,
|
||||
// making all symbols available to the linker.
|
||||
#include "DeviceObserver.m"
|
||||
|
||||
|
||||
#define CHK(condition, status) \
|
||||
do { \
|
||||
if(!(condition)) { \
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
// MIT License
|
||||
//
|
||||
// Copyright (c) 2019-2020 Pion
|
||||
//
|
||||
// Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
// of this software and associated documentation files (the "Software"), to deal
|
||||
// in the Software without restriction, including without limitation the rights
|
||||
// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
// copies of the Software, and to permit persons to whom the Software is
|
||||
// furnished to do so, subject to the following conditions:
|
||||
//
|
||||
// The above copyright notice and this permission notice shall be included in all
|
||||
// copies or substantial portions of the Software.
|
||||
//
|
||||
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
// SOFTWARE.
|
||||
|
||||
#ifndef DEVICEOBSERVER_H
|
||||
#define DEVICEOBSERVER_H
|
||||
|
||||
#include "AVFoundationBind.h"
|
||||
|
||||
typedef const char* STATUS;
|
||||
|
||||
typedef struct {
|
||||
char uid[MAX_DEVICE_UID_CHARS + 1]; // +1 for null terminator
|
||||
char name[MAX_DEVICE_NAME_CHARS + 1];
|
||||
} DeviceInfo;
|
||||
|
||||
typedef enum {
|
||||
DeviceEventConnected = 0,
|
||||
DeviceEventDisconnected = 1
|
||||
} DeviceEventType;
|
||||
|
||||
// Callback function type for device events
|
||||
// userData: user-provided context pointer
|
||||
// eventType: connected or disconnected
|
||||
// device: device info
|
||||
typedef void (*DeviceEventCallback)(void *userData, DeviceEventType eventType, DeviceInfo *device);
|
||||
|
||||
// Initialize the device observer with a callback
|
||||
// Returns NULL on success, error string on failure
|
||||
STATUS DeviceObserverInit(DeviceEventCallback callback, void *userData);
|
||||
|
||||
// Start observing device events (notifications will be delivered via the run loop)
|
||||
STATUS DeviceObserverStart(void);
|
||||
|
||||
// Stop observing device events
|
||||
STATUS DeviceObserverStop(void);
|
||||
|
||||
// Cleanup the device observer
|
||||
STATUS DeviceObserverDestroy(void);
|
||||
|
||||
// Get current list of video devices
|
||||
// devices: output array (must have space for MAX_DEVICES)
|
||||
// count: output count of devices found
|
||||
STATUS DeviceObserverGetDevices(DeviceInfo *devices, int *count);
|
||||
|
||||
// Run the run loop for a specified duration (in seconds)
|
||||
// This allows the observer to receive notifications
|
||||
STATUS DeviceObserverRunFor(double seconds);
|
||||
|
||||
#endif // DEVICEOBSERVER_H
|
||||
@@ -0,0 +1,260 @@
|
||||
// MIT License
|
||||
//
|
||||
// Copyright (c) 2019-2020 Pion
|
||||
//
|
||||
// Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
// of this software and associated documentation files (the "Software"), to deal
|
||||
// in the Software without restriction, including without limitation the rights
|
||||
// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
// copies of the Software, and to permit persons to whom the Software is
|
||||
// furnished to do so, subject to the following conditions:
|
||||
//
|
||||
// The above copyright notice and this permission notice shall be included in all
|
||||
// copies or substantial portions of the Software.
|
||||
//
|
||||
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
// SOFTWARE.
|
||||
|
||||
// Naming Convention (let "name" as an actual variable name):
|
||||
// - mName: "name" is a member of an Objective C object
|
||||
// - pName: "name" is a C pointer
|
||||
// - refName: "name" is an Objective C object reference
|
||||
|
||||
#import <Foundation/Foundation.h>
|
||||
#import <AVFoundation/AVFoundation.h>
|
||||
#import <string.h>
|
||||
#import "DeviceObserver.h"
|
||||
|
||||
|
||||
extern void goDeviceEventCallback(void *pUserData, int eventType, DeviceInfo *pDevice);
|
||||
|
||||
void deviceEventBridge(void *pUserData, DeviceEventType eventType, DeviceInfo *pDevice) {
|
||||
goDeviceEventCallback(pUserData, (int)eventType, pDevice);
|
||||
}
|
||||
|
||||
@interface DeviceObserverDelegate : NSObject {
|
||||
DeviceEventCallback mCallback;
|
||||
void *mUserData;
|
||||
AVCaptureDeviceDiscoverySession *mDiscoverySession;
|
||||
BOOL mObserving;
|
||||
}
|
||||
@end
|
||||
|
||||
@implementation DeviceObserverDelegate
|
||||
|
||||
- (instancetype)initWithCallback:(DeviceEventCallback)callback userData:(void *)pUserData {
|
||||
self = [super init];
|
||||
if (self) {
|
||||
mCallback = callback;
|
||||
mUserData = pUserData;
|
||||
mObserving = NO;
|
||||
|
||||
NSArray *refDeviceTypes = @[
|
||||
AVCaptureDeviceTypeBuiltInWideAngleCamera,
|
||||
AVCaptureDeviceTypeExternal
|
||||
];
|
||||
|
||||
mDiscoverySession = [[AVCaptureDeviceDiscoverySession
|
||||
discoverySessionWithDeviceTypes:refDeviceTypes
|
||||
mediaType:AVMediaTypeVideo
|
||||
position:AVCaptureDevicePositionUnspecified] retain];
|
||||
}
|
||||
return self;
|
||||
}
|
||||
|
||||
- (void)startObserving {
|
||||
if (mObserving) return;
|
||||
|
||||
[mDiscoverySession addObserver:self
|
||||
forKeyPath:@"devices"
|
||||
options:(NSKeyValueObservingOptionOld | NSKeyValueObservingOptionNew)
|
||||
context:nil];
|
||||
|
||||
mObserving = YES;
|
||||
}
|
||||
|
||||
- (void)stopObserving {
|
||||
if (!mObserving) return;
|
||||
|
||||
[mDiscoverySession removeObserver:self forKeyPath:@"devices"];
|
||||
mObserving = NO;
|
||||
}
|
||||
|
||||
- (void)observeValueForKeyPath:(NSString *)keyPath
|
||||
ofObject:(id)object
|
||||
change:(NSDictionary<NSKeyValueChangeKey,id> *)change
|
||||
context:(void *)pContext {
|
||||
|
||||
if (![keyPath isEqualToString:@"devices"]) return;
|
||||
|
||||
NSArray<AVCaptureDevice *> *refOldDevices = change[NSKeyValueChangeOldKey];
|
||||
NSArray<AVCaptureDevice *> *refNewDevices = change[NSKeyValueChangeNewKey];
|
||||
|
||||
if ([refOldDevices isKindOfClass:[NSNull class]]) refOldDevices = @[];
|
||||
if ([refNewDevices isKindOfClass:[NSNull class]]) refNewDevices = @[];
|
||||
|
||||
// Build sets of device UIDs for comparison
|
||||
NSMutableSet *refOldUIDs = [NSMutableSet set];
|
||||
NSMutableDictionary *refOldDeviceMap = [NSMutableDictionary dictionary];
|
||||
for (AVCaptureDevice *refDevice in refOldDevices) {
|
||||
[refOldUIDs addObject:refDevice.uniqueID];
|
||||
refOldDeviceMap[refDevice.uniqueID] = refDevice;
|
||||
}
|
||||
|
||||
NSMutableSet *refNewUIDs = [NSMutableSet set];
|
||||
NSMutableDictionary *refNewDeviceMap = [NSMutableDictionary dictionary];
|
||||
for (AVCaptureDevice *refDevice in refNewDevices) {
|
||||
[refNewUIDs addObject:refDevice.uniqueID];
|
||||
refNewDeviceMap[refDevice.uniqueID] = refDevice;
|
||||
}
|
||||
|
||||
// Find added devices
|
||||
NSMutableSet *refAddedUIDs = [refNewUIDs mutableCopy];
|
||||
[refAddedUIDs minusSet:refOldUIDs];
|
||||
|
||||
// Find removed devices
|
||||
NSMutableSet *refRemovedUIDs = [refOldUIDs mutableCopy];
|
||||
[refRemovedUIDs minusSet:refNewUIDs];
|
||||
|
||||
// Notify about added devices
|
||||
for (NSString *uid in refAddedUIDs) {
|
||||
AVCaptureDevice *refDevice = refNewDeviceMap[uid];
|
||||
DeviceInfo info;
|
||||
memset(&info, 0, sizeof(info));
|
||||
strlcpy(info.uid, refDevice.uniqueID.UTF8String, sizeof(info.uid));
|
||||
strlcpy(info.name, refDevice.localizedName.UTF8String, sizeof(info.name));
|
||||
|
||||
if (mCallback) {
|
||||
mCallback(mUserData, DeviceEventConnected, &info);
|
||||
}
|
||||
}
|
||||
|
||||
// Notify about removed devices
|
||||
for (NSString *uid in refRemovedUIDs) {
|
||||
AVCaptureDevice *refDevice = refOldDeviceMap[uid];
|
||||
DeviceInfo info;
|
||||
memset(&info, 0, sizeof(info));
|
||||
strlcpy(info.uid, refDevice.uniqueID.UTF8String, sizeof(info.uid));
|
||||
strlcpy(info.name, refDevice.localizedName.UTF8String, sizeof(info.name));
|
||||
|
||||
if (mCallback) {
|
||||
mCallback(mUserData, DeviceEventDisconnected, &info);
|
||||
}
|
||||
}
|
||||
|
||||
[refAddedUIDs release];
|
||||
[refRemovedUIDs release];
|
||||
}
|
||||
|
||||
- (void)dealloc {
|
||||
[self stopObserving];
|
||||
[mDiscoverySession release];
|
||||
[super dealloc];
|
||||
}
|
||||
|
||||
@end
|
||||
|
||||
// Global observer instance
|
||||
static DeviceObserverDelegate *refObserver = nil;
|
||||
|
||||
STATUS DeviceObserverInit(DeviceEventCallback callback, void *pUserData) {
|
||||
@autoreleasepool {
|
||||
if (refObserver != nil) {
|
||||
return "observer already initialized";
|
||||
}
|
||||
|
||||
refObserver = [[DeviceObserverDelegate alloc] initWithCallback:callback userData:pUserData];
|
||||
if (refObserver == nil) {
|
||||
return "failed to create observer";
|
||||
}
|
||||
|
||||
return STATUS_OK;
|
||||
}
|
||||
}
|
||||
|
||||
STATUS DeviceObserverStart(void) {
|
||||
@autoreleasepool {
|
||||
if (refObserver == nil) {
|
||||
return "observer not initialized";
|
||||
}
|
||||
|
||||
[refObserver startObserving];
|
||||
return STATUS_OK;
|
||||
}
|
||||
}
|
||||
|
||||
STATUS DeviceObserverStop(void) {
|
||||
@autoreleasepool {
|
||||
if (refObserver == nil) {
|
||||
return "observer not initialized";
|
||||
}
|
||||
|
||||
[refObserver stopObserving];
|
||||
return STATUS_OK;
|
||||
}
|
||||
}
|
||||
|
||||
STATUS DeviceObserverDestroy(void) {
|
||||
@autoreleasepool {
|
||||
if (refObserver == nil) {
|
||||
return "observer not initialized";
|
||||
}
|
||||
|
||||
[refObserver stopObserving];
|
||||
[refObserver release];
|
||||
refObserver = nil;
|
||||
|
||||
return STATUS_OK;
|
||||
}
|
||||
}
|
||||
|
||||
STATUS DeviceObserverGetDevices(DeviceInfo *pDevices, int *pCount) {
|
||||
@autoreleasepool {
|
||||
if (pDevices == NULL || pCount == NULL) {
|
||||
return "invalid arguments";
|
||||
}
|
||||
|
||||
// Use discovery session for device enumeration
|
||||
NSArray *refDeviceTypes = @[
|
||||
AVCaptureDeviceTypeBuiltInWideAngleCamera,
|
||||
AVCaptureDeviceTypeExternal
|
||||
];
|
||||
|
||||
AVCaptureDeviceDiscoverySession *refSession = [AVCaptureDeviceDiscoverySession
|
||||
discoverySessionWithDeviceTypes:refDeviceTypes
|
||||
mediaType:AVMediaTypeVideo
|
||||
position:AVCaptureDevicePositionUnspecified];
|
||||
|
||||
int i = 0;
|
||||
for (AVCaptureDevice *refDevice in refSession.devices) {
|
||||
if (i >= MAX_DEVICES) break;
|
||||
|
||||
memset(&pDevices[i], 0, sizeof(DeviceInfo));
|
||||
strlcpy(pDevices[i].uid, refDevice.uniqueID.UTF8String, sizeof(pDevices[i].uid));
|
||||
strlcpy(pDevices[i].name, refDevice.localizedName.UTF8String, sizeof(pDevices[i].name));
|
||||
i++;
|
||||
}
|
||||
|
||||
*pCount = i;
|
||||
return STATUS_OK;
|
||||
}
|
||||
}
|
||||
|
||||
STATUS DeviceObserverRunFor(double seconds) {
|
||||
@autoreleasepool {
|
||||
// Add a timer to keep the run loop alive
|
||||
NSTimer *refTimer = [NSTimer scheduledTimerWithTimeInterval:seconds
|
||||
target:[NSDate class]
|
||||
selector:@selector(date)
|
||||
userInfo:nil
|
||||
repeats:NO];
|
||||
[[NSRunLoop currentRunLoop] runUntilDate:[NSDate dateWithTimeIntervalSinceNow:seconds]];
|
||||
[refTimer invalidate];
|
||||
return STATUS_OK;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,386 @@
|
||||
package avfoundation
|
||||
|
||||
/*
|
||||
#cgo CFLAGS: -x objective-c
|
||||
#cgo LDFLAGS: -framework AVFoundation -framework Foundation -framework CoreMedia -framework CoreVideo
|
||||
#include <stdlib.h>
|
||||
#include <string.h>
|
||||
#include "AVFoundationBind/DeviceObserver.h"
|
||||
|
||||
extern void deviceEventBridge(void *userData, DeviceEventType eventType, DeviceInfo *device);
|
||||
|
||||
static const char* DeviceObserverInitWithBridge() {
|
||||
return DeviceObserverInit(deviceEventBridge, NULL);
|
||||
}
|
||||
*/
|
||||
import "C"
|
||||
import (
|
||||
"fmt"
|
||||
"runtime"
|
||||
"sync"
|
||||
"unsafe"
|
||||
)
|
||||
|
||||
type observerStateType int
|
||||
|
||||
const (
|
||||
observerInitial observerStateType = iota
|
||||
observerSetup // KVO initialized on main thread but not pumping run loop
|
||||
observerStarting // Starting run loop (transitioning to running)
|
||||
observerRunning // Run loop is actively pumping
|
||||
observerDestroyed // Destroyed and cannot be restarted
|
||||
)
|
||||
|
||||
// deviceObserver manages the AVFoundation device observer lifecycle with the singleton pattern.
|
||||
// The observer is single-use. Once DestroyObserver is called, it cannot be restarted.
|
||||
type deviceObserver struct {
|
||||
// Signals observer to transition to the startup state
|
||||
signalStart chan struct{}
|
||||
// Signals observer to destroy and stop pumping the NSRunLoop in the bg routine (if running)
|
||||
signalDestroy chan struct{}
|
||||
// Closed when setup state logic completes.
|
||||
setupDone chan struct{}
|
||||
// Closed when startup state logic completes.
|
||||
startDone chan struct{}
|
||||
// Coordinates waiting for the observer goroutine to complete
|
||||
wg sync.WaitGroup
|
||||
|
||||
// mu protects all below state fields.
|
||||
// Must not be held when invoking user callbacks to avoid deadlock (double lock acquisition).
|
||||
mu sync.Mutex
|
||||
deviceCache map[string]Device
|
||||
state observerStateType
|
||||
onDeviceChange func(Device, DeviceEventType)
|
||||
setupErr error
|
||||
}
|
||||
|
||||
var (
|
||||
observerSingleton *deviceObserver
|
||||
observerSingletonOnce sync.Once
|
||||
)
|
||||
|
||||
func getObserver() *deviceObserver {
|
||||
observerSingletonOnce.Do(func() {
|
||||
observerSingleton = &deviceObserver{
|
||||
deviceCache: make(map[string]Device),
|
||||
state: observerInitial,
|
||||
}
|
||||
})
|
||||
return observerSingleton
|
||||
}
|
||||
|
||||
type DeviceEventType int
|
||||
|
||||
const (
|
||||
DeviceEventConnected DeviceEventType = C.DeviceEventConnected
|
||||
DeviceEventDisconnected DeviceEventType = C.DeviceEventDisconnected
|
||||
)
|
||||
|
||||
func SetOnDeviceChange(f func(Device, DeviceEventType)) {
|
||||
obs := getObserver()
|
||||
obs.mu.Lock()
|
||||
defer obs.mu.Unlock()
|
||||
obs.onDeviceChange = f
|
||||
}
|
||||
|
||||
func createDevice(uid, name string) Device {
|
||||
var d Device
|
||||
d.UID = uid
|
||||
d.Name = name
|
||||
|
||||
// Copy strings to C char arrays
|
||||
cUID := C.CString(uid)
|
||||
defer C.free(unsafe.Pointer(cUID))
|
||||
C.strncpy(&d.cDevice.uid[0], cUID, C.MAX_DEVICE_UID_CHARS)
|
||||
d.cDevice.uid[C.MAX_DEVICE_UID_CHARS] = 0
|
||||
|
||||
cName := C.CString(name)
|
||||
defer C.free(unsafe.Pointer(cName))
|
||||
C.strncpy(&d.cDevice.name[0], cName, C.MAX_DEVICE_NAME_CHARS)
|
||||
d.cDevice.name[C.MAX_DEVICE_NAME_CHARS] = 0
|
||||
|
||||
return d
|
||||
}
|
||||
|
||||
//export goDeviceEventCallback
|
||||
func goDeviceEventCallback(userData unsafe.Pointer, eventType C.int, device *C.DeviceInfo) {
|
||||
uid := C.GoString(&device.uid[0])
|
||||
name := C.GoString(&device.name[0])
|
||||
|
||||
d := createDevice(uid, name)
|
||||
et := DeviceEventType(eventType)
|
||||
|
||||
obs := getObserver()
|
||||
obs.mu.Lock()
|
||||
if eventType == C.DeviceEventConnected {
|
||||
obs.deviceCache[uid] = d
|
||||
} else if eventType == C.DeviceEventDisconnected {
|
||||
delete(obs.deviceCache, uid)
|
||||
}
|
||||
cb := obs.onDeviceChange
|
||||
obs.mu.Unlock()
|
||||
|
||||
if cb != nil {
|
||||
cb(d, et)
|
||||
}
|
||||
}
|
||||
|
||||
// setup initializes the device observer and starts a goroutine locked to a thread for NSRunLoop,
|
||||
// but does not begin pumping the run loop yet. The goroutine waits idle until start is called.
|
||||
// This function assumes the caller invoked it from the main thread to set up AVFoundation KVO properly.
|
||||
func (obs *deviceObserver) setup() error {
|
||||
obs.mu.Lock()
|
||||
|
||||
switch obs.state {
|
||||
case observerInitial:
|
||||
// Continue with setup
|
||||
case observerSetup, observerStarting, observerRunning:
|
||||
// Already setup or beyond
|
||||
obs.mu.Unlock()
|
||||
return nil
|
||||
case observerDestroyed:
|
||||
obs.mu.Unlock()
|
||||
return fmt.Errorf("device observer is single-use and was destroyed, so it cannot be restarted")
|
||||
}
|
||||
|
||||
if obs.setupDone != nil {
|
||||
done := obs.setupDone
|
||||
obs.mu.Unlock()
|
||||
<-done
|
||||
obs.mu.Lock()
|
||||
err := obs.setupErr
|
||||
obs.mu.Unlock()
|
||||
return err
|
||||
}
|
||||
|
||||
// We're first to setup, initialize the channels
|
||||
obs.signalStart = make(chan struct{})
|
||||
obs.signalDestroy = make(chan struct{})
|
||||
obs.setupDone = make(chan struct{})
|
||||
obs.startDone = make(chan struct{})
|
||||
obs.setupErr = nil
|
||||
obs.wg.Add(1)
|
||||
obs.mu.Unlock()
|
||||
|
||||
go func() {
|
||||
defer obs.wg.Done()
|
||||
runtime.LockOSThread()
|
||||
defer runtime.UnlockOSThread()
|
||||
|
||||
var err error
|
||||
if status := C.DeviceObserverInitWithBridge(); status != nil {
|
||||
err = fmt.Errorf("failed to init observer: %s", C.GoString(status))
|
||||
} else if status := C.DeviceObserverStart(); status != nil {
|
||||
C.DeviceObserverDestroy() // remember to clean up C objects on error
|
||||
err = fmt.Errorf("failed to start observer: %s", C.GoString(status))
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
obs.mu.Lock()
|
||||
obs.state = observerInitial
|
||||
obs.setupErr = err
|
||||
obs.mu.Unlock()
|
||||
close(obs.setupDone)
|
||||
return
|
||||
}
|
||||
|
||||
// Populate device cache and prepare initial device list for callbacks
|
||||
var devices [C.MAX_DEVICES]C.DeviceInfo
|
||||
var count C.int
|
||||
status := C.DeviceObserverGetDevices(&devices[0], &count)
|
||||
var initialDevices []Device
|
||||
obs.mu.Lock()
|
||||
if status == nil {
|
||||
obs.deviceCache = make(map[string]Device)
|
||||
for i := 0; i < int(count); i++ {
|
||||
uid := C.GoString(&devices[i].uid[0])
|
||||
name := C.GoString(&devices[i].name[0])
|
||||
dev := createDevice(uid, name)
|
||||
obs.deviceCache[uid] = dev
|
||||
initialDevices = append(initialDevices, dev)
|
||||
}
|
||||
}
|
||||
obs.state = observerSetup
|
||||
obs.mu.Unlock()
|
||||
|
||||
close(obs.setupDone)
|
||||
|
||||
// STATE BOUNDARY: setup phase complete, now entering startup phase
|
||||
obs.waitForStartAndRun(initialDevices)
|
||||
}()
|
||||
|
||||
<-obs.setupDone // waits for goroutine to complete setup
|
||||
obs.mu.Lock()
|
||||
err := obs.setupErr
|
||||
obs.mu.Unlock()
|
||||
return err
|
||||
}
|
||||
|
||||
// waitForStartAndRun waits for the start signal, then transitions to running state
|
||||
// and pumps the NSRunLoop.
|
||||
func (obs *deviceObserver) waitForStartAndRun(initialDevices []Device) {
|
||||
// Wait for signal to start pumping or destroy
|
||||
select {
|
||||
case <-obs.signalDestroy:
|
||||
C.DeviceObserverStop()
|
||||
C.DeviceObserverDestroy()
|
||||
close(obs.startDone)
|
||||
return
|
||||
case <-obs.signalStart:
|
||||
// Transition to running
|
||||
}
|
||||
|
||||
obs.mu.Lock()
|
||||
cb := obs.onDeviceChange
|
||||
obs.state = observerRunning
|
||||
obs.mu.Unlock()
|
||||
|
||||
close(obs.startDone)
|
||||
|
||||
// Replay current devices
|
||||
if cb != nil {
|
||||
for _, dev := range initialDevices {
|
||||
cb(dev, DeviceEventConnected)
|
||||
}
|
||||
}
|
||||
|
||||
// STATE BOUNDARY: startup -> running
|
||||
for {
|
||||
select {
|
||||
case <-obs.signalDestroy:
|
||||
// STATE BOUNDARY: running -> destroyed
|
||||
C.DeviceObserverStop()
|
||||
C.DeviceObserverDestroy()
|
||||
return
|
||||
default:
|
||||
C.DeviceObserverRunFor(0.1)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// start signals the observer goroutine to begin pumping the run loop.
|
||||
func (obs *deviceObserver) start() error {
|
||||
obs.mu.Lock()
|
||||
|
||||
for {
|
||||
switch obs.state {
|
||||
case observerInitial:
|
||||
// Need to setup first
|
||||
obs.mu.Unlock()
|
||||
if err := obs.setup(); err != nil {
|
||||
return err
|
||||
}
|
||||
obs.mu.Lock()
|
||||
continue // re-check state as it may have changed by another goroutine e.g. destroyed
|
||||
case observerStarting:
|
||||
// Another goroutine is starting the run loop; wait on same result
|
||||
done := obs.startDone
|
||||
obs.mu.Unlock()
|
||||
<-done
|
||||
return nil
|
||||
case observerRunning:
|
||||
obs.mu.Unlock()
|
||||
return nil
|
||||
case observerDestroyed:
|
||||
obs.mu.Unlock()
|
||||
return fmt.Errorf("cannot start observer: observer has been destroyed and cannot be restarted")
|
||||
case observerSetup:
|
||||
// Proceed to signal start
|
||||
}
|
||||
break
|
||||
}
|
||||
|
||||
obs.state = observerStarting
|
||||
pump := obs.signalStart
|
||||
obs.mu.Unlock()
|
||||
|
||||
close(pump)
|
||||
|
||||
<-obs.startDone
|
||||
return nil
|
||||
}
|
||||
|
||||
// destroy destroys the device observer and releases all C/Objective-C resources.
|
||||
// The observer cannot be restarted after being destroyed.
|
||||
func (obs *deviceObserver) destroy() error {
|
||||
obs.mu.Lock()
|
||||
|
||||
for {
|
||||
switch obs.state {
|
||||
case observerInitial:
|
||||
obs.state = observerDestroyed
|
||||
destroy := obs.signalDestroy
|
||||
obs.mu.Unlock()
|
||||
if destroy != nil { // may be nil if setup wasn't called
|
||||
close(destroy)
|
||||
obs.wg.Wait()
|
||||
}
|
||||
return nil
|
||||
case observerDestroyed:
|
||||
obs.mu.Unlock()
|
||||
return nil
|
||||
case observerSetup, observerRunning:
|
||||
// Set state to destroyed before unlocking to prevent concurrent destroy
|
||||
obs.state = observerDestroyed
|
||||
case observerStarting:
|
||||
// Wait for transition to running
|
||||
done := obs.startDone
|
||||
obs.mu.Unlock()
|
||||
<-done
|
||||
obs.mu.Lock() // lock and check state again
|
||||
continue
|
||||
}
|
||||
break
|
||||
}
|
||||
|
||||
destroy := obs.signalDestroy
|
||||
obs.mu.Unlock()
|
||||
|
||||
close(destroy)
|
||||
obs.wg.Wait()
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// SetupObserver initializes the device observer and starts a goroutine
|
||||
// locked to a thread for NSRunLoop, but does not begin pumping the run loop yet.
|
||||
// The goroutine waits idle until StartObserver is called, avoiding CPU overhead.
|
||||
// Safe to call concurrently and idempotently.
|
||||
func SetupObserver() error {
|
||||
return getObserver().setup()
|
||||
}
|
||||
|
||||
// StartObserver signals the observer goroutine to begin pumping the run loop.
|
||||
// If SetupObserver has not been called, StartObserver will call it first.
|
||||
// Safe to call concurrently and idempotently.
|
||||
func StartObserver() error {
|
||||
return getObserver().start()
|
||||
}
|
||||
|
||||
// DestroyObserver destroys the device observer and releases all C/Objective-C resources.
|
||||
// The observer is single-use and cannot be restarted after being destroyed.
|
||||
// Safe to call concurrently and idempotently.
|
||||
func DestroyObserver() error {
|
||||
return getObserver().destroy()
|
||||
}
|
||||
|
||||
// LookupCachedDevice returns the cached device that matches the provided UID.
|
||||
// The returned boolean indicates whether the device was present in the cache.
|
||||
// Callers should verify IsObserverRunning before relying on the result.
|
||||
func LookupCachedDevice(uid string) (Device, bool) {
|
||||
obs := getObserver()
|
||||
obs.mu.Lock()
|
||||
defer obs.mu.Unlock()
|
||||
|
||||
dev, ok := obs.deviceCache[uid]
|
||||
return dev, ok
|
||||
}
|
||||
|
||||
// IsObserverRunning reports whether the device observer has successfully started
|
||||
// and populated the in-memory cache.
|
||||
func IsObserverRunning() bool {
|
||||
obs := getObserver()
|
||||
obs.mu.Lock()
|
||||
defer obs.mu.Unlock()
|
||||
return obs.state == observerRunning
|
||||
}
|
||||
@@ -0,0 +1,297 @@
|
||||
//go:build darwin
|
||||
// +build darwin
|
||||
|
||||
package avfoundation
|
||||
|
||||
import (
|
||||
"testing"
|
||||
)
|
||||
|
||||
// TestGetObserverSingleton tests that getObserver returns the same instance.
|
||||
func TestGetObserverSingleton(t *testing.T) {
|
||||
obs1 := getObserver()
|
||||
obs2 := getObserver()
|
||||
|
||||
if obs1 != obs2 {
|
||||
t.Error("getObserver() should return the same singleton instance")
|
||||
}
|
||||
|
||||
if obs1.deviceCache == nil {
|
||||
t.Error("Observer device cache should be initialized")
|
||||
}
|
||||
|
||||
if obs1.state != observerInitial {
|
||||
t.Errorf("Initial observer state should be observerInitial, got: %v", obs1.state)
|
||||
}
|
||||
}
|
||||
|
||||
// TestCreateDevice tests device creation with UID and name.
|
||||
func TestCreateDevice(t *testing.T) {
|
||||
testCases := []struct {
|
||||
name string
|
||||
uid string
|
||||
devName string
|
||||
}{
|
||||
{
|
||||
name: "simple device",
|
||||
uid: "test-uid-123",
|
||||
devName: "Test Camera",
|
||||
},
|
||||
{
|
||||
name: "device with special characters",
|
||||
uid: "camera_0x1234567890abcdef",
|
||||
devName: "FaceTime HD Camera",
|
||||
},
|
||||
{
|
||||
name: "empty strings",
|
||||
uid: "",
|
||||
devName: "",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range testCases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
device := createDevice(tc.uid, tc.devName)
|
||||
|
||||
if device.UID != tc.uid {
|
||||
t.Errorf("Expected UID %q, got %q", tc.uid, device.UID)
|
||||
}
|
||||
|
||||
if device.Name != tc.devName {
|
||||
t.Errorf("Expected Name %q, got %q", tc.devName, device.Name)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestSetOnDeviceChange tests setting and retrieving the device change callback.
|
||||
func TestSetOnDeviceChange(t *testing.T) {
|
||||
// Reset observer state for clean test (hacky)
|
||||
// In production, the observer is a singleton
|
||||
obs := getObserver()
|
||||
obs.mu.Lock()
|
||||
originalCallback := obs.onDeviceChange
|
||||
obs.mu.Unlock()
|
||||
|
||||
// Restore original callback at end of test
|
||||
defer func() {
|
||||
obs.mu.Lock()
|
||||
obs.onDeviceChange = originalCallback
|
||||
obs.mu.Unlock()
|
||||
}()
|
||||
|
||||
called := false
|
||||
var capturedDevice Device
|
||||
var capturedEvent DeviceEventType
|
||||
|
||||
SetOnDeviceChange(func(d Device, e DeviceEventType) {
|
||||
called = true
|
||||
capturedDevice = d
|
||||
capturedEvent = e
|
||||
})
|
||||
|
||||
// Verify callback was set
|
||||
obs.mu.Lock()
|
||||
if obs.onDeviceChange == nil {
|
||||
t.Fatal("OnDeviceChange callback was not set")
|
||||
}
|
||||
|
||||
// Manually trigger callback for testing
|
||||
testDevice := createDevice("test-uid", "test-name")
|
||||
testEvent := DeviceEventConnected
|
||||
cb := obs.onDeviceChange
|
||||
obs.mu.Unlock()
|
||||
|
||||
if cb != nil {
|
||||
cb(testDevice, testEvent)
|
||||
}
|
||||
|
||||
if !called {
|
||||
t.Error("Callback was not invoked")
|
||||
}
|
||||
|
||||
if capturedDevice.UID != "test-uid" {
|
||||
t.Errorf("Expected captured UID %q, got %q", "test-uid", capturedDevice.UID)
|
||||
}
|
||||
|
||||
if capturedEvent != DeviceEventConnected {
|
||||
t.Errorf("Expected event %v, got %v", DeviceEventConnected, capturedEvent)
|
||||
}
|
||||
}
|
||||
|
||||
// TestLookupCachedDevice tests device cache lookups.
|
||||
func TestLookupCachedDevice(t *testing.T) {
|
||||
obs := getObserver()
|
||||
|
||||
// Add a test device to cache
|
||||
testUID := "lookup-test-uid"
|
||||
testDevice := createDevice(testUID, "Lookup Test Camera")
|
||||
|
||||
obs.mu.Lock()
|
||||
obs.deviceCache[testUID] = testDevice
|
||||
obs.mu.Unlock()
|
||||
|
||||
// Test successful lookup
|
||||
device, ok := LookupCachedDevice(testUID)
|
||||
if !ok {
|
||||
t.Error("Expected to find device in cache")
|
||||
}
|
||||
|
||||
if device.UID != testUID {
|
||||
t.Errorf("Expected UID %q, got %q", testUID, device.UID)
|
||||
}
|
||||
|
||||
// Test failed lookup
|
||||
_, ok = LookupCachedDevice("non-existent-uid")
|
||||
if ok {
|
||||
t.Error("Expected not to find non-existent device in cache")
|
||||
}
|
||||
|
||||
// Cleanup
|
||||
obs.mu.Lock()
|
||||
delete(obs.deviceCache, testUID)
|
||||
obs.mu.Unlock()
|
||||
}
|
||||
|
||||
// TestIsObserverRunning tests the observer running state check.
|
||||
func TestIsObserverRunning(t *testing.T) {
|
||||
obs := getObserver()
|
||||
|
||||
// Initially should not be running
|
||||
obs.mu.Lock()
|
||||
originalState := obs.state
|
||||
obs.state = observerInitial
|
||||
obs.mu.Unlock()
|
||||
|
||||
// Restore original state at end
|
||||
defer func() {
|
||||
obs.mu.Lock()
|
||||
obs.state = originalState
|
||||
obs.mu.Unlock()
|
||||
}()
|
||||
|
||||
if IsObserverRunning() {
|
||||
t.Error("Observer should not be running in initial state")
|
||||
}
|
||||
|
||||
// Set state to running
|
||||
obs.mu.Lock()
|
||||
obs.state = observerRunning
|
||||
obs.mu.Unlock()
|
||||
|
||||
if !IsObserverRunning() {
|
||||
t.Error("Observer should be running after state set to observerRunning")
|
||||
}
|
||||
|
||||
// Set state to other states
|
||||
for _, state := range []observerStateType{observerSetup, observerStarting, observerDestroyed} {
|
||||
obs.mu.Lock()
|
||||
obs.state = state
|
||||
obs.mu.Unlock()
|
||||
|
||||
if IsObserverRunning() {
|
||||
t.Errorf("Observer should not be running in state %v", state)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestGoDeviceEventCallback tests the C-to-Go device event callback.
|
||||
func TestGoDeviceEventCallback(t *testing.T) {
|
||||
obs := getObserver()
|
||||
|
||||
// Clear device cache for clean test
|
||||
obs.mu.Lock()
|
||||
obs.deviceCache = make(map[string]Device)
|
||||
originalCallback := obs.onDeviceChange
|
||||
obs.mu.Unlock()
|
||||
|
||||
defer func() {
|
||||
obs.mu.Lock()
|
||||
obs.onDeviceChange = originalCallback
|
||||
obs.deviceCache = make(map[string]Device)
|
||||
obs.mu.Unlock()
|
||||
}()
|
||||
|
||||
// Set up test callback
|
||||
var callbackInvoked bool
|
||||
var capturedDevice Device
|
||||
var capturedEvent DeviceEventType
|
||||
|
||||
SetOnDeviceChange(func(d Device, e DeviceEventType) {
|
||||
callbackInvoked = true
|
||||
capturedDevice = d
|
||||
capturedEvent = e
|
||||
})
|
||||
|
||||
// Note: We cannot directly call goDeviceEventCallback with C types in a Go test
|
||||
// without CGO setup. Instead, we test the logic that would be executed.
|
||||
|
||||
// Simulate connect event
|
||||
testUID := "callback-test-uid"
|
||||
testDevice := createDevice(testUID, "Callback Test Camera")
|
||||
|
||||
obs.mu.Lock()
|
||||
obs.deviceCache[testUID] = testDevice
|
||||
cb := obs.onDeviceChange
|
||||
obs.mu.Unlock()
|
||||
|
||||
if cb != nil {
|
||||
cb(testDevice, DeviceEventConnected)
|
||||
}
|
||||
|
||||
if !callbackInvoked {
|
||||
t.Error("User callback should have been invoked")
|
||||
}
|
||||
|
||||
if capturedEvent != DeviceEventConnected {
|
||||
t.Errorf("Expected DeviceEventConnected, got %v", capturedEvent)
|
||||
}
|
||||
|
||||
// Verify device was added to cache
|
||||
obs.mu.Lock()
|
||||
_, exists := obs.deviceCache[testUID]
|
||||
obs.mu.Unlock()
|
||||
|
||||
if !exists {
|
||||
t.Error("Device should be in cache after connect event")
|
||||
}
|
||||
|
||||
// Simulate disconnect event
|
||||
callbackInvoked = false
|
||||
obs.mu.Lock()
|
||||
delete(obs.deviceCache, testUID)
|
||||
cb = obs.onDeviceChange
|
||||
obs.mu.Unlock()
|
||||
|
||||
if cb != nil {
|
||||
cb(testDevice, DeviceEventDisconnected)
|
||||
}
|
||||
|
||||
if !callbackInvoked {
|
||||
t.Error("User callback should have been invoked for disconnect")
|
||||
}
|
||||
|
||||
if capturedEvent != DeviceEventDisconnected {
|
||||
t.Errorf("Expected DeviceEventDisconnected, got %v", capturedEvent)
|
||||
}
|
||||
|
||||
if capturedDevice.UID != testUID {
|
||||
t.Errorf("Expected captured device UID %q, got %q", testUID, capturedDevice.UID)
|
||||
}
|
||||
|
||||
// Verify device was removed from cache
|
||||
obs.mu.Lock()
|
||||
_, exists = obs.deviceCache[testUID]
|
||||
obs.mu.Unlock()
|
||||
|
||||
if exists {
|
||||
t.Error("Device should not be in cache after disconnect event")
|
||||
}
|
||||
}
|
||||
|
||||
// TestDeviceEventTypes tests the device event type constants, verifying that they are different.
|
||||
func TestDeviceEventTypes(t *testing.T) {
|
||||
if DeviceEventConnected == DeviceEventDisconnected {
|
||||
t.Error("DeviceEventConnected and DeviceEventDisconnected should be different")
|
||||
}
|
||||
}
|
||||
@@ -5,9 +5,10 @@ import (
|
||||
)
|
||||
|
||||
var (
|
||||
ErrUnimplemented = NewError("not implemented")
|
||||
ErrBusy = NewError("device or resource busy")
|
||||
ErrNoDevice = NewError("no such device")
|
||||
ErrObserverUnavailable = NewError("observer unavailable (not started or destroyed)")
|
||||
ErrUnimplemented = NewError("not implemented")
|
||||
ErrBusy = NewError("device or resource busy")
|
||||
ErrNoDevice = NewError("no such device")
|
||||
)
|
||||
|
||||
type errorString struct {
|
||||
|
||||
@@ -9,6 +9,7 @@ import (
|
||||
|
||||
"github.com/pion/mediadevices/pkg/avfoundation"
|
||||
"github.com/pion/mediadevices/pkg/driver"
|
||||
"github.com/pion/mediadevices/pkg/driver/availability"
|
||||
"github.com/pion/mediadevices/pkg/frame"
|
||||
"github.com/pion/mediadevices/pkg/io/video"
|
||||
"github.com/pion/mediadevices/pkg/prop"
|
||||
@@ -44,6 +45,72 @@ func Initialize() {
|
||||
}
|
||||
}
|
||||
|
||||
// SetupObserver initializes the device observer on the main thread without starting monitoring.
|
||||
// This allows setup on the main thread (required by macOS) without CPU overhead until StartObserver is called.
|
||||
// The caller must invoke SetupObserver from the main thread for proper NSRunLoop setup.
|
||||
// Safe to call concurrently and idempotent; multiple calls are no-ops if already setup.
|
||||
func SetupObserver() error {
|
||||
manager := driver.GetManager()
|
||||
|
||||
avfoundation.SetOnDeviceChange(func(device avfoundation.Device, event avfoundation.DeviceEventType) {
|
||||
switch event {
|
||||
case avfoundation.DeviceEventConnected:
|
||||
drivers := manager.Query(func(d driver.Driver) bool {
|
||||
return d.Info().Label == device.UID
|
||||
})
|
||||
if len(drivers) > 0 {
|
||||
return
|
||||
}
|
||||
|
||||
cam := newCamera(device)
|
||||
manager.Register(cam, driver.Info{
|
||||
Label: device.UID,
|
||||
DeviceType: driver.Camera,
|
||||
Name: device.Name,
|
||||
})
|
||||
|
||||
case avfoundation.DeviceEventDisconnected:
|
||||
drivers := manager.Query(func(d driver.Driver) bool {
|
||||
return d.Info().Label == device.UID
|
||||
})
|
||||
for _, d := range drivers {
|
||||
status := d.Status()
|
||||
if status != driver.StateClosed {
|
||||
if err := d.Close(); err != nil {
|
||||
}
|
||||
}
|
||||
manager.Delete(d.ID())
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
return avfoundation.SetupObserver()
|
||||
}
|
||||
|
||||
// StartObserver starts the background observer to monitor for device changes.
|
||||
// If SetupObserver has not been called, StartObserver will call it first.
|
||||
// Safe to call concurrently and idempotently.
|
||||
func StartObserver() error {
|
||||
// Call SetupObserver first to ensure SetOnDeviceChange callback is registered.
|
||||
// This is safe as observer methods are idempotent and handle concurrency.
|
||||
if err := SetupObserver(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := avfoundation.StartObserver(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return syncVideoRecorders(driver.GetManager())
|
||||
}
|
||||
|
||||
// DestroyObserver destroys the device observer and releases all resources.
|
||||
// The observer is single-use and cannot be restarted after being destroyed.
|
||||
// Safe to call concurrently and idempotently.
|
||||
func DestroyObserver() error {
|
||||
return avfoundation.DestroyObserver()
|
||||
}
|
||||
|
||||
func newCamera(device avfoundation.Device) *camera {
|
||||
return &camera{
|
||||
device: device,
|
||||
@@ -57,15 +124,20 @@ func (cam *camera) Open() error {
|
||||
}
|
||||
|
||||
func (cam *camera) Close() error {
|
||||
if cam.rcClose != nil {
|
||||
cam.rcClose()
|
||||
}
|
||||
|
||||
if cam.cancel != nil {
|
||||
cam.cancel()
|
||||
cam.cancel = nil
|
||||
}
|
||||
|
||||
return cam.session.Close()
|
||||
if cam.rcClose != nil {
|
||||
cam.rcClose()
|
||||
cam.rcClose = nil
|
||||
}
|
||||
if cam.session != nil {
|
||||
err := cam.session.Close()
|
||||
cam.session = nil
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (cam *camera) VideoRecord(property prop.Media) (video.Reader, error) {
|
||||
@@ -106,3 +178,68 @@ func (cam *camera) VideoRecord(property prop.Media) (video.Reader, error) {
|
||||
func (cam *camera) Properties() []prop.Media {
|
||||
return cam.session.Properties()
|
||||
}
|
||||
|
||||
func (cam *camera) IsAvailable() (bool, error) {
|
||||
if !avfoundation.IsObserverRunning() {
|
||||
return false, availability.ErrObserverUnavailable
|
||||
}
|
||||
|
||||
if _, ok := avfoundation.LookupCachedDevice(cam.device.UID); !ok {
|
||||
return false, availability.ErrNoDevice
|
||||
}
|
||||
|
||||
// Probe device availability by attempting to open a session
|
||||
session, err := avfoundation.NewSession(cam.device)
|
||||
if err != nil {
|
||||
return false, availability.ErrBusy
|
||||
}
|
||||
if session == nil {
|
||||
panic("session was nil while error was nil")
|
||||
}
|
||||
session.Close()
|
||||
|
||||
return true, nil
|
||||
}
|
||||
|
||||
// syncVideoRecorders keeps the manager in lockstep with the hardware before the first user query.
|
||||
func syncVideoRecorders(manager *driver.Manager) error {
|
||||
devices, err := avfoundation.Devices(avfoundation.Video)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
current := make(map[string]struct{}, len(devices))
|
||||
for _, device := range devices {
|
||||
current[device.UID] = struct{}{}
|
||||
}
|
||||
|
||||
registered := manager.Query(driver.FilterVideoRecorder())
|
||||
registeredByLabel := make(map[string]struct{}, len(registered))
|
||||
|
||||
// drop any registered drivers whose UID isn't currently present
|
||||
for _, d := range registered {
|
||||
label := d.Info().Label
|
||||
registeredByLabel[label] = struct{}{}
|
||||
if _, ok := current[label]; !ok {
|
||||
manager.Delete(d.ID())
|
||||
delete(registeredByLabel, label)
|
||||
}
|
||||
}
|
||||
|
||||
// register any new devices that appeared between the init() call and the observer start
|
||||
for _, device := range devices {
|
||||
if _, ok := registeredByLabel[device.UID]; ok {
|
||||
continue
|
||||
}
|
||||
|
||||
cam := newCamera(device)
|
||||
manager.Register(cam, driver.Info{
|
||||
Label: device.UID,
|
||||
DeviceType: driver.Camera,
|
||||
Name: device.Name,
|
||||
})
|
||||
registeredByLabel[device.UID] = struct{}{}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
//go:build darwin
|
||||
// +build darwin
|
||||
|
||||
// $ go test -v . -tags darwin -run="^TestCameraFrameFormatSupport$"
|
||||
@@ -8,6 +9,8 @@ import (
|
||||
"testing"
|
||||
|
||||
"github.com/pion/mediadevices/pkg/avfoundation"
|
||||
"github.com/pion/mediadevices/pkg/driver"
|
||||
"github.com/pion/mediadevices/pkg/driver/availability"
|
||||
"github.com/pion/mediadevices/pkg/frame"
|
||||
"github.com/pion/mediadevices/pkg/prop"
|
||||
)
|
||||
@@ -61,3 +64,138 @@ func TestCameraFrameFormatSupport(t *testing.T) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestCameraCloseIdempotency tests that Close can be called multiple times safely.
|
||||
func TestCameraCloseIdempotency(t *testing.T) {
|
||||
devices, err := avfoundation.Devices(avfoundation.Video)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if len(devices) == 0 {
|
||||
t.Skip("No video devices available for testing")
|
||||
}
|
||||
|
||||
cam := newCamera(devices[0])
|
||||
if err := cam.Open(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// Close multiple times should not error
|
||||
for i := 0; i < 3; i++ {
|
||||
if err := cam.Close(); err != nil {
|
||||
t.Errorf("Close call %d failed: %v", i+1, err)
|
||||
}
|
||||
}
|
||||
|
||||
// Verify internal state was cleared
|
||||
if cam.session != nil {
|
||||
t.Error("Session should be nil after close")
|
||||
}
|
||||
if cam.rcClose != nil {
|
||||
t.Error("rcClose should be nil after close")
|
||||
}
|
||||
if cam.cancel != nil {
|
||||
t.Error("cancel should be nil after close")
|
||||
}
|
||||
}
|
||||
|
||||
// TestCameraIsAvailableObserverNotRunning tests IsAvailable when observer is not running.
|
||||
func TestCameraIsAvailableObserverNotRunning(t *testing.T) {
|
||||
devices, err := avfoundation.Devices(avfoundation.Video)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if len(devices) == 0 {
|
||||
t.Skip("No video devices available for testing")
|
||||
}
|
||||
|
||||
cam := newCamera(devices[0])
|
||||
|
||||
available, err := cam.IsAvailable()
|
||||
if available {
|
||||
t.Error("Camera should not be available when observer is not running")
|
||||
}
|
||||
|
||||
if err != availability.ErrObserverUnavailable {
|
||||
t.Errorf("Expected ErrObserverUnavailable, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestNewCamera tests camera constructor.
|
||||
func TestNewCamera(t *testing.T) {
|
||||
testDevice := avfoundation.Device{
|
||||
UID: "test-uid",
|
||||
Name: "Test Camera",
|
||||
}
|
||||
|
||||
cam := newCamera(testDevice)
|
||||
|
||||
if cam == nil {
|
||||
t.Fatal("newCamera returned nil")
|
||||
}
|
||||
|
||||
if cam.device.UID != testDevice.UID {
|
||||
t.Errorf("Expected device UID %q, got %q", testDevice.UID, cam.device.UID)
|
||||
}
|
||||
|
||||
if cam.device.Name != testDevice.Name {
|
||||
t.Errorf("Expected device name %q, got %q", testDevice.Name, cam.device.Name)
|
||||
}
|
||||
}
|
||||
|
||||
// TestSyncVideoRecorders tests the syncVideoRecorders function.
|
||||
func TestSyncVideoRecorders(t *testing.T) {
|
||||
manager := driver.GetManager()
|
||||
|
||||
// Initial state
|
||||
initialDrivers := manager.Query(driver.FilterVideoRecorder())
|
||||
initialCount := len(initialDrivers)
|
||||
|
||||
// Run sync
|
||||
err := syncVideoRecorders(manager)
|
||||
if err != nil {
|
||||
t.Fatalf("syncVideoRecorders failed: %v", err)
|
||||
}
|
||||
|
||||
// Verify drivers were synced
|
||||
afterDrivers := manager.Query(driver.FilterVideoRecorder())
|
||||
afterCount := len(afterDrivers)
|
||||
|
||||
// The count should match the actual devices available
|
||||
devices, err := avfoundation.Devices(avfoundation.Video)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if afterCount != len(devices) {
|
||||
t.Logf("Warning: Expected %d drivers after sync, got %d (initial: %d)",
|
||||
len(devices), afterCount, initialCount)
|
||||
}
|
||||
}
|
||||
|
||||
// TestObserverFunctionsIdempotent tests that observer functions can be called multiple times.
|
||||
func TestObserverFunctionsIdempotent(t *testing.T) {
|
||||
// This test may have side effects on the global observer state
|
||||
// In a real scenario, you'd want to reset the observer between tests
|
||||
|
||||
// SetupObserver should be idempotent
|
||||
for i := 0; i < 2; i++ {
|
||||
if err := SetupObserver(); err != nil {
|
||||
t.Errorf("SetupObserver call %d failed: %v", i+1, err)
|
||||
}
|
||||
}
|
||||
|
||||
// StartObserver should be idempotent
|
||||
for i := 0; i < 2; i++ {
|
||||
if err := StartObserver(); err != nil {
|
||||
t.Errorf("StartObserver call %d failed: %v", i+1, err)
|
||||
}
|
||||
}
|
||||
|
||||
// Cleanup
|
||||
if err := DestroyObserver(); err != nil {
|
||||
t.Errorf("DestroyObserver failed: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -98,6 +98,21 @@ func Initialize() {
|
||||
discover(discovered, "/dev/video*")
|
||||
}
|
||||
|
||||
// SetupObserver is a stub implementation for Linux.
|
||||
func SetupObserver() error {
|
||||
return availability.ErrUnimplemented
|
||||
}
|
||||
|
||||
// StartObserver is a stub implementation for Linux.
|
||||
func StartObserver() error {
|
||||
return availability.ErrUnimplemented
|
||||
}
|
||||
|
||||
// DestroyObserver is a stub implementation for Linux.
|
||||
func DestroyObserver() error {
|
||||
return availability.ErrUnimplemented
|
||||
}
|
||||
|
||||
func discover(discovered map[string]struct{}, pattern string) {
|
||||
devices, err := filepath.Glob(pattern)
|
||||
if err != nil {
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
//go:build linux || windows
|
||||
|
||||
package camera
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"testing"
|
||||
|
||||
"github.com/pion/mediadevices/pkg/driver/availability"
|
||||
)
|
||||
|
||||
// TestSetupObserver tests the stub implementation of SetupObserver.
|
||||
func TestSetupObserver(t *testing.T) {
|
||||
err := SetupObserver()
|
||||
if !errors.Is(err, availability.ErrUnimplemented) {
|
||||
t.Errorf("SetupObserver() should return ErrUnimplemented for stub implementation, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestStartObserver tests the stub implementation of StartObserver.
|
||||
func TestStartObserver(t *testing.T) {
|
||||
err := StartObserver()
|
||||
if !errors.Is(err, availability.ErrUnimplemented) {
|
||||
t.Errorf("StartObserver() should return ErrUnimplemented for stub implementation, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestDestroyObserver tests the stub implementation of DestroyObserver.
|
||||
func TestDestroyObserver(t *testing.T) {
|
||||
err := DestroyObserver()
|
||||
if !errors.Is(err, availability.ErrUnimplemented) {
|
||||
t.Errorf("DestroyObserver() should return ErrUnimplemented for stub implementation, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestObserverFunctionsIdempotent tests that observer functions can be called multiple times safely.
|
||||
func TestObserverFunctionsIdempotent(t *testing.T) {
|
||||
for i := 0; i < 3; i++ {
|
||||
if err := SetupObserver(); !errors.Is(err, availability.ErrUnimplemented) {
|
||||
t.Errorf("SetupObserver() call %d should return ErrUnimplemented, got: %v", i+1, err)
|
||||
}
|
||||
if err := StartObserver(); !errors.Is(err, availability.ErrUnimplemented) {
|
||||
t.Errorf("StartObserver() call %d should return ErrUnimplemented, got: %v", i+1, err)
|
||||
}
|
||||
}
|
||||
|
||||
for i := 0; i < 3; i++ {
|
||||
if err := DestroyObserver(); !errors.Is(err, availability.ErrUnimplemented) {
|
||||
t.Errorf("DestroyObserver() call %d should return ErrUnimplemented, got: %v", i+1, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -13,6 +13,7 @@ import (
|
||||
"unsafe"
|
||||
|
||||
"github.com/pion/mediadevices/pkg/driver"
|
||||
"github.com/pion/mediadevices/pkg/driver/availability"
|
||||
"github.com/pion/mediadevices/pkg/frame"
|
||||
"github.com/pion/mediadevices/pkg/io/video"
|
||||
"github.com/pion/mediadevices/pkg/prop"
|
||||
@@ -58,6 +59,21 @@ func Initialize() {
|
||||
C.freeCameraList(&list, &errStr)
|
||||
}
|
||||
|
||||
// SetupObserver is a stub implementation for Windows.
|
||||
func SetupObserver() error {
|
||||
return availability.ErrUnimplemented
|
||||
}
|
||||
|
||||
// StartObserver is a stub implementation for Windows.
|
||||
func StartObserver() error {
|
||||
return availability.ErrUnimplemented
|
||||
}
|
||||
|
||||
// DestroyObserver is a stub implementation for Windows.
|
||||
func DestroyObserver() error {
|
||||
return availability.ErrUnimplemented
|
||||
}
|
||||
|
||||
func (c *camera) Open() error {
|
||||
c.ch = make(chan []byte)
|
||||
c.cam = &C.camera{
|
||||
|
||||
Reference in New Issue
Block a user