Add device enumeration

This commit is contained in:
Milan Nikolic
2026-06-30 13:34:27 +02:00
parent b766a53a29
commit 636c388cba
11 changed files with 224 additions and 9 deletions

View File

@@ -40,6 +40,8 @@ This command will install `cam2ip` in `GOBIN`, you can point `GOBIN` to e.g. `/u
Usage: cam2ip [<flags>]
--index
Camera index [CAM2IP_INDEX] (default "0")
--device
Camera name to use, matched as substring, overrides index [CAM2IP_DEVICE] (default "")
--delay
Delay between frames, in milliseconds [CAM2IP_DELAY] (default "10")
--width
@@ -62,6 +64,8 @@ Usage: cam2ip [<flags>]
Bind address [CAM2IP_BIND_ADDR] (default ":56000")
--htpasswd-file
Path to htpasswd file, if empty auth is disabled [CAM2IP_HTPASSWD_FILE] (default "")
--list-devices
List available cameras and exit (default "false")
--version
Print version and exit (default "false")
```

View File

@@ -16,6 +16,12 @@ type Options struct {
TimeFormat string
}
// DeviceInfo describes a capture device.
type DeviceInfo struct {
Index int
Name string
}
var (
yuy2FourCC = fourcc("YUY2")
yuyvFourCC = fourcc("YUYV")

View File

@@ -296,3 +296,8 @@ func (c *Camera) Close() (err error) {
return
}
// Devices returns the available capture devices.
func Devices() ([]DeviceInfo, error) {
return nil, fmt.Errorf("camera: device enumeration not implemented on android")
}

View File

@@ -100,6 +100,52 @@ func New(opts Options) (c *Camera, err error) {
return c, nil
}
// Devices returns the available capture devices.
func Devices() ([]DeviceInfo, error) {
if err := loadFrameworks(); err != nil {
return nil, err
}
pool := objc.ID(objc.GetClass("NSAutoreleasePool")).Send(selAlloc).Send(selInit)
defer pool.Send(selDrain)
avCaptureDevice := objc.ID(objc.GetClass("AVCaptureDevice"))
list := avCaptureDevice.Send(selDevicesWithMediaType, avMediaTypeVideo)
count := int(objc.Send[uint64](list, selCount))
devices := make([]DeviceInfo, 0, count)
for i := 0; i < count; i++ {
device := list.Send(selObjectAtIndex, uint64(i))
devices = append(devices, DeviceInfo{Index: i, Name: goString(device.Send(selLocalizedName))})
}
return devices, nil
}
// goString converts an NSString to a Go string.
func goString(s objc.ID) string {
if s == 0 {
return ""
}
p := objc.Send[*byte](s, selUTF8String)
if p == nil {
return ""
}
var b []byte
for i := 0; ; i++ {
ch := *(*byte)(unsafe.Add(unsafe.Pointer(p), i))
if ch == 0 {
break
}
b = append(b, ch)
}
return string(b)
}
// Read reads next frame from camera and returns image.
func (c *Camera) Read() (img image.Image, err error) {
c.mu.Lock()
@@ -366,6 +412,8 @@ var (
selSetSampleBufferQueue = objc.RegisterName("setSampleBufferDelegate:queue:")
selStartRunning = objc.RegisterName("startRunning")
selStopRunning = objc.RegisterName("stopRunning")
selLocalizedName = objc.RegisterName("localizedName")
selUTF8String = objc.RegisterName("UTF8String")
)
var (

View File

@@ -214,3 +214,20 @@ func (c *Camera) Close() (err error) {
return
}
// Devices returns the available capture devices.
func Devices() ([]DeviceInfo, error) {
infos := v4l.FindDevices()
devices := make([]DeviceInfo, 0, len(infos))
for i, d := range infos {
name := d.DeviceName
if name == "" {
name = d.Path
}
devices = append(devices, DeviceInfo{Index: i, Name: name})
}
return devices, nil
}

View File

@@ -25,6 +25,7 @@ var (
iidIMFMediaSource = guid{0x279a808d, 0xaec7, 0x40c8, [8]byte{0x9c, 0x6b, 0xa6, 0xb4, 0x92, 0xc7, 0x8a, 0x66}}
mfDevsourceAttributeSourceType = guid{0xc60ac5fe, 0x252a, 0x478f, [8]byte{0xa0, 0xef, 0xbc, 0x8f, 0xa5, 0xf7, 0xca, 0xd3}}
mfDevsourceAttributeSourceTypeVidcap = guid{0x8ac3587a, 0x4ae7, 0x42d8, [8]byte{0x99, 0xe0, 0x0a, 0x60, 0x13, 0xee, 0xf9, 0x0f}}
mfDevsourceAttributeFriendlyName = guid{0x60d0e559, 0x52f8, 0x4fa2, [8]byte{0xbb, 0xce, 0xac, 0xdb, 0x34, 0xa8, 0xec, 0x01}}
mfSourceReaderEnableVideoProcessing = guid{0xfb394f3d, 0xccf1, 0x42ee, [8]byte{0xbb, 0xb3, 0xf9, 0xb8, 0x45, 0xd5, 0x68, 0x1d}}
mfMTMajorType = guid{0x48eba18e, 0xf8c9, 0x4687, [8]byte{0xbf, 0x11, 0x0a, 0x74, 0xc9, 0xf9, 0x6a, 0x8f}}
mfMTSubtype = guid{0xf7e34c9a, 0x42e8, 0x4714, [8]byte{0xb7, 0x4b, 0xcb, 0x29, 0xd7, 0x2c, 0x35, 0xe5}}
@@ -448,6 +449,70 @@ func nv12ToYCbCr420(data []byte, stride, width, height int, dst *image.YCbCr) {
}
}
// Devices returns the available capture devices.
func Devices() ([]DeviceInfo, error) {
runtime.LockOSThread()
defer runtime.UnlockOSThread()
for _, p := range []*syscall.LazyProc{procCoInitializeEx, procMFStartup, procMFCreateAttributes, procMFEnumDeviceSources} {
if err := p.Find(); err != nil {
return nil, fmt.Errorf("camera: %s: %w", p.Name, err)
}
}
if r, _, _ := procCoInitializeEx.Call(0, coinitMultithreaded); failed(r) && uint32(r) != rpcEChangedMode {
return nil, fmt.Errorf("camera: CoInitializeEx: %#x", r)
}
if r, _, _ := procMFStartup.Call(mfVersion, mfstartupLite); failed(r) {
return nil, fmt.Errorf("camera: MFStartup: %#x", r)
}
defer procMFShutdown.Call()
defer procCoUninitialize.Call()
var attr unsafe.Pointer
if r, _, _ := procMFCreateAttributes.Call(uintptr(unsafe.Pointer(&attr)), 1); failed(r) {
return nil, fmt.Errorf("camera: MFCreateAttributes: %#x", r)
}
attrSetGUID(attr, &mfDevsourceAttributeSourceType, &mfDevsourceAttributeSourceTypeVidcap)
var list *unsafe.Pointer
var count uint32
r, _, _ := procMFEnumDeviceSources.Call(uintptr(attr), uintptr(unsafe.Pointer(&list)), uintptr(unsafe.Pointer(&count)))
release(attr)
if failed(r) {
return nil, fmt.Errorf("camera: MFEnumDeviceSources: %#x", r)
}
defer procCoTaskMemFree.Call(uintptr(unsafe.Pointer(list)))
activates := unsafe.Slice(list, count)
devices := make([]DeviceInfo, 0, count)
for i, a := range activates {
devices = append(devices, DeviceInfo{Index: i, Name: friendlyName(a)})
release(a)
}
return devices, nil
}
// friendlyName reads MF_DEVSOURCE_ATTRIBUTE_FRIENDLY_NAME from an activate object.
func friendlyName(activate unsafe.Pointer) string {
var str *uint16
var length uint32
if r := comCall(activate, 13, uintptr(unsafe.Pointer(&mfDevsourceAttributeFriendlyName)), uintptr(unsafe.Pointer(&str)), uintptr(unsafe.Pointer(&length))); failed(r) {
return ""
}
defer procCoTaskMemFree.Call(uintptr(unsafe.Pointer(str)))
if str == nil {
return ""
}
return syscall.UTF16ToString(unsafe.Slice(str, length))
}
// comCall invokes the method at the given vtable index on a COM object.
func comCall(obj unsafe.Pointer, index int, args ...uintptr) uintptr {
vtbl := *(*unsafe.Pointer)(obj)

View File

@@ -279,8 +279,9 @@ var (
registerClassExW = user32.NewProc("RegisterClassExW")
unregisterClassW = user32.NewProc("UnregisterClassW")
getModuleHandleW = kernel32.NewProc("GetModuleHandleW")
capCreateCaptureWindowW = avicap32.NewProc("capCreateCaptureWindowW")
getModuleHandleW = kernel32.NewProc("GetModuleHandleW")
capCreateCaptureWindowW = avicap32.NewProc("capCreateCaptureWindowW")
capGetDriverDescriptionW = avicap32.NewProc("capGetDriverDescriptionW")
)
const (
@@ -481,3 +482,28 @@ func capCreateCaptureWindow(lpszWindowName string, dwStyle, x, y, width, height
return syscall.Handle(ret), nil
}
// Devices returns the available capture devices.
func Devices() ([]DeviceInfo, error) {
var devices []DeviceInfo
for i := 0; i < 10; i++ {
var name [128]uint16
ret, _, _ := capGetDriverDescriptionW.Call(uintptr(i),
uintptr(unsafe.Pointer(&name[0])), unsafe.Sizeof(name),
0, 0)
if ret == 0 {
continue
}
n := syscall.UTF16ToString(name[:])
if n == "" {
continue
}
devices = append(devices, DeviceInfo{Index: i, Name: n})
}
return devices, nil
}

View File

@@ -5,6 +5,7 @@ import (
"fmt"
"os"
"runtime/debug"
"strings"
"go.senan.xyz/flagconf"
@@ -44,6 +45,7 @@ func main() {
srv := server.NewServer()
flag.IntVar(&srv.Index, "index", 0, "Camera index [CAM2IP_INDEX]")
flag.StringVar(&srv.Device, "device", "", "Camera name to use, matched as substring, overrides index [CAM2IP_DEVICE]")
flag.IntVar(&srv.Delay, "delay", 10, "Delay between frames, in milliseconds [CAM2IP_DELAY]")
flag.Float64Var(&srv.Width, "width", 640, "Frame width [CAM2IP_WIDTH]")
flag.Float64Var(&srv.Height, "height", 480, "Frame height [CAM2IP_HEIGHT]")
@@ -59,12 +61,15 @@ func main() {
var showVersion bool
flag.BoolVar(&showVersion, "version", false, "Print version and exit")
var listDevices bool
flag.BoolVar(&listDevices, "list-devices", false, "List available cameras and exit")
flag.Usage = func() {
color := useColor(os.Stderr)
stderr("%s %s [<flags>]\n", colorize(color, colorBold, "Usage:"), name)
order := []string{"index", "delay", "width", "height", "quality", "rotate", "flip", "no-webgl",
"timestamp", "time-format", "bind-addr", "htpasswd-file", "version"}
order := []string{"index", "device", "delay", "width", "height", "quality", "rotate", "flip", "no-webgl",
"timestamp", "time-format", "bind-addr", "htpasswd-file", "list-devices", "version"}
for _, name := range order {
f := flag.Lookup(name)
@@ -82,6 +87,30 @@ func main() {
os.Exit(0)
}
if listDevices {
devices, err := camera.Devices()
if err != nil {
stderr("%s\n", err.Error())
os.Exit(1)
}
for _, d := range devices {
fmt.Printf("%d: %s\n", d.Index, d.Name)
}
os.Exit(0)
}
if srv.Device != "" {
index, err := deviceIndex(srv.Device)
if err != nil {
stderr("%s\n", err.Error())
os.Exit(1)
}
srv.Index = index
}
srv.Name = name
srv.Version = version
@@ -123,6 +152,23 @@ func stderr(format string, a ...any) {
_, _ = fmt.Fprintf(os.Stderr, format, a...)
}
// deviceIndex returns the index of the first camera whose name contains the query.
func deviceIndex(name string) (int, error) {
devices, err := camera.Devices()
if err != nil {
return 0, err
}
want := strings.ToLower(name)
for _, d := range devices {
if strings.Contains(strings.ToLower(d.Name), want) {
return d.Index, nil
}
}
return 0, fmt.Errorf("camera: no device matching %q", name)
}
const (
colorBold = "\033[1m"
colorCyan = "\033[36m"

1
go.mod
View File

@@ -10,7 +10,6 @@ require (
github.com/pbnjay/pixfont v0.0.0-20200714042608-33b744692567
github.com/pixiv/go-libjpeg v0.0.0-20190822045933-3da21a74767d
go.senan.xyz/flagconf v0.1.11
gocv.io/x/gocv v0.43.0
)
require (

2
go.sum
View File

@@ -18,8 +18,6 @@ github.com/rogpeppe/go-internal v1.12.0 h1:exVL4IDcn6na9z1rAb56Vxr+CgyK3nn3O+epU
github.com/rogpeppe/go-internal v1.12.0/go.mod h1:E+RYuTGaKKdloAfM02xzb0FW3Paa99yedzYV+kq4uf4=
go.senan.xyz/flagconf v0.1.11 h1:ApA9DpoSrfVQlLt8spKJC3fOT7oTEZ2/MmBGMnb9mt4=
go.senan.xyz/flagconf v0.1.11/go.mod h1:NqOFfSwJvNWXOTUabcRZ8mPK9+sJmhStJhqtEt74wNQ=
gocv.io/x/gocv v0.43.0 h1:PFNpRUcV8fgBRDbVHHN+4BDZjjPnVveo5N/+e15BTuA=
gocv.io/x/gocv v0.43.0/go.mod h1:zYdWMj29WAEznM3Y8NsU3A0TRq/wR/cy75jeUypThqU=
golang.org/x/crypto v0.53.0 h1:QZ4Muo8THX6CizN2vPPd5fBGHyogrdK9fG4wLPFUsto=
golang.org/x/crypto v0.53.0/go.mod h1:DNLU434OwVakk9PzuwV8w62mAJpRJL3vsgcfp4Qnsio=
golang.org/x/net v0.56.0 h1:Rw8j/hFzGvJUZwNBXnAtf5sVDVt+65SK2C7IxCxZt5o=

View File

@@ -17,8 +17,9 @@ type Server struct {
Name string
Version string
Index int
Delay int
Index int
Device string
Delay int
Width float64
Height float64