rubycam 0.1.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +7 -0
- data/LICENSE +21 -0
- data/README.md +148 -0
- data/TINY4LINUX_FEATURES.md +76 -0
- data/exe/rubycam +14 -0
- data/lib/rubycam/cli.rb +324 -0
- data/lib/rubycam/controls.rb +45 -0
- data/lib/rubycam/device.rb +267 -0
- data/lib/rubycam/ioctl.rb +60 -0
- data/lib/rubycam/obsbot.rb +176 -0
- data/lib/rubycam/version.rb +5 -0
- data/lib/rubycam.rb +23 -0
- data/rubycam.gemspec +47 -0
- metadata +131 -0
checksums.yaml
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
---
|
|
2
|
+
SHA256:
|
|
3
|
+
metadata.gz: 56159c34d1f4c3fc3b773260aab3bca1428ea7e431a7a28481285d6059917293
|
|
4
|
+
data.tar.gz: eb0006ec99edf167df7220c21a0d88e0d6e97c9c9d09751cb9dd7fbf25861e7b
|
|
5
|
+
SHA512:
|
|
6
|
+
metadata.gz: 58a93e7f44420ffcef22e953b078a19a97451748b8cadf8ab22fea9d75f09bb27f879c8b8284b56048911fc2ff43df93e9ad796d008c15c6bf7bf2d75e4f9eec
|
|
7
|
+
data.tar.gz: b206af6d57365dd79f82e3636d41728195ba1ceded40a4f5d7d06cb02e92607f5015fa26d1b5d463b716b284044beaf1264188c6192c0efac598090db1681c19
|
data/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Nathan Kidd
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
data/README.md
ADDED
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
# rubycam
|
|
2
|
+
|
|
3
|
+
Pure-Ruby V4L2 webcam library plus a GTK4 viewer, built against an OBSBOT
|
|
4
|
+
Tiny 2. No C extension and no GStreamer: controls go through `ioctl` and
|
|
5
|
+
frames come from memory-mapped kernel buffers via Fiddle.
|
|
6
|
+
|
|
7
|
+
The OBSBOT is a standard UVC device, so the kernel's `uvcvideo` driver
|
|
8
|
+
already handles it — this library talks V4L2 and works with any UVC webcam.
|
|
9
|
+
|
|
10
|
+
## Install
|
|
11
|
+
|
|
12
|
+
Two gems ship from this repo:
|
|
13
|
+
|
|
14
|
+
| Gem | Provides | Depends on |
|
|
15
|
+
| ------------- | ------------------------------------------ | ------------------- |
|
|
16
|
+
| `rubycam` | the V4L2/OBSBOT library and the `rubycam` CLI | `dry-cli` |
|
|
17
|
+
| `rubycam-gtk` | the GTK4 viewer and the `rubycam-gtk` app | `rubycam`, `gtk4` |
|
|
18
|
+
|
|
19
|
+
```sh
|
|
20
|
+
gem install rubycam # library + CLI (pure Ruby, light install)
|
|
21
|
+
gem install rubycam-gtk # adds the GTK4 viewer (pulls in the GTK stack)
|
|
22
|
+
```
|
|
23
|
+
|
|
24
|
+
The `rubycam` gem has no dependencies beyond `dry-cli`; the GTK stack only
|
|
25
|
+
comes in with `rubycam-gtk`.
|
|
26
|
+
|
|
27
|
+
## Library
|
|
28
|
+
|
|
29
|
+
```ruby
|
|
30
|
+
require 'rubycam'
|
|
31
|
+
|
|
32
|
+
Rubycam::Device.open('/dev/video0') do |cam|
|
|
33
|
+
cam.controls.each_value { |c| puts c } # discover controls
|
|
34
|
+
cam[:zoom_absolute] = 50 # get/set by symbol
|
|
35
|
+
cam[:pan_absolute] = 20 * 3600 # gimbal units: 1/3600 degree
|
|
36
|
+
|
|
37
|
+
cam.set_format(width: 1920, height: 1080, pixel_format: 'MJPG')
|
|
38
|
+
cam.set_fps(30)
|
|
39
|
+
File.binwrite('frame.jpg', cam.capture_frame) # blocking
|
|
40
|
+
cam.poll_frame # non-blocking, nil if not ready
|
|
41
|
+
end
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
OBSBOT vendor commands go through the camera's UVC extension unit — the
|
|
45
|
+
same channel the official OBSBOT software uses (protocol from the
|
|
46
|
+
[Tiny4Linux](https://github.com/OpenFoxes/Tiny4Linux) project; see
|
|
47
|
+
`TINY4LINUX_FEATURES.md` for the full feature map):
|
|
48
|
+
|
|
49
|
+
```ruby
|
|
50
|
+
cam = Rubycam::Device.find('OBSBOT Tiny 2') # path, /dev name or card/bus hint
|
|
51
|
+
bot = Rubycam::Obsbot.new(cam)
|
|
52
|
+
bot.status # => { asleep: false, hdr: true, ai_mode: :no_tracking,
|
|
53
|
+
# tracking_speed: :standard }
|
|
54
|
+
bot.sleep! # privacy sleep (gimbal folds down)
|
|
55
|
+
bot.wake! # wakes even after the camera was folded down by hand
|
|
56
|
+
|
|
57
|
+
bot.ai_mode = :normal_tracking # :no_tracking :upper_body :close_up :headless
|
|
58
|
+
# :lower_body :desk_mode :whiteboard :hand :group
|
|
59
|
+
bot.tracking_speed = :sport # or :standard
|
|
60
|
+
bot.goto_preset(0) # stored gimbal positions 0..2
|
|
61
|
+
bot.hdr = true
|
|
62
|
+
bot.exposure_mode = :face # :manual, :global or :face
|
|
63
|
+
|
|
64
|
+
bot.debug = true # log raw traffic to stderr
|
|
65
|
+
bot.send_hex('16 02 00 00') # raw command to the extension unit
|
|
66
|
+
bot.dump # current status block as hex
|
|
67
|
+
```
|
|
68
|
+
|
|
69
|
+
Notes:
|
|
70
|
+
|
|
71
|
+
- The camera delivers 4K@30 / 1080p@60 in MJPG; frames are JPEG strings.
|
|
72
|
+
- Folding the camera down by hand is privacy sleep; V4L2 keeps working but
|
|
73
|
+
video stops. `Obsbot#wake!` is the only software way back.
|
|
74
|
+
- Writing `tilt_absolute` to its minimum does NOT trigger privacy sleep —
|
|
75
|
+
that gesture is hardware-only.
|
|
76
|
+
- The first frame after STREAMON takes a few seconds (ISP startup).
|
|
77
|
+
- On newer Tiny 2 firmware the status block lags mode changes by seconds
|
|
78
|
+
and reports tracking speed at byte 0x24 instead of 0x21 (both handled).
|
|
79
|
+
|
|
80
|
+
## Viewer app
|
|
81
|
+
|
|
82
|
+
With `rubycam-gtk` installed the viewer is on your PATH:
|
|
83
|
+
|
|
84
|
+
```sh
|
|
85
|
+
rubycam-gtk # first camera at /dev/video0
|
|
86
|
+
rubycam-gtk /dev/video2 # explicit device
|
|
87
|
+
rubycam-gtk 'OBSBOT Tiny 2' # find by name
|
|
88
|
+
|
|
89
|
+
rubycam-gtk --obsbot # full OBSBOT viewer (default)
|
|
90
|
+
rubycam-gtk --v4l2 # generic V4L2 viewer, no OBSBOT features
|
|
91
|
+
```
|
|
92
|
+
|
|
93
|
+
From a checkout, use the dev shell (it provides the native GTK libs):
|
|
94
|
+
|
|
95
|
+
```sh
|
|
96
|
+
nix develop # or let direnv do it (.envrc: use flake)
|
|
97
|
+
bundle install # or bin/setup
|
|
98
|
+
rubycam-gtk # exe/ is on PATH via direnv
|
|
99
|
+
```
|
|
100
|
+
|
|
101
|
+
Live preview, sliders for gimbal/zoom/image controls, and an OBSBOT panel
|
|
102
|
+
with power switch, live status, AI tracking modes, tracking speed, preset
|
|
103
|
+
positions, HDR, exposure modes and a raw-hex debug console. The ⤢ button
|
|
104
|
+
toggles a compact widget mode (panel only); if the camera disappears the
|
|
105
|
+
app keeps polling and reconnects when it returns.
|
|
106
|
+
|
|
107
|
+
`--v4l2` launches the same live preview and control sliders without any of
|
|
108
|
+
the OBSBOT panel or vendor commands, so it works with any UVC webcam.
|
|
109
|
+
|
|
110
|
+
## CLI
|
|
111
|
+
|
|
112
|
+
Everything the OBSBOT panel does, scriptable — shipped by the `rubycam`
|
|
113
|
+
gem (needs only Ruby + the `dry-cli` gem, no GTK):
|
|
114
|
+
|
|
115
|
+
```sh
|
|
116
|
+
rubycam # list commands
|
|
117
|
+
rubycam status # sleep state, AI mode, speed, HDR
|
|
118
|
+
rubycam wake # wake from privacy sleep
|
|
119
|
+
rubycam track upper_body # AI tracking mode
|
|
120
|
+
rubycam speed sport
|
|
121
|
+
rubycam preset 2 # gimbal preset 1-3
|
|
122
|
+
rubycam hdr off
|
|
123
|
+
rubycam exposure face
|
|
124
|
+
|
|
125
|
+
rubycam devices # every /dev/video* node
|
|
126
|
+
rubycam controls # V4L2 controls with ranges
|
|
127
|
+
rubycam set zoom_absolute 50
|
|
128
|
+
rubycam reset # all controls back to defaults
|
|
129
|
+
rubycam snapshot shot.jpg --width=3840 --height=2160
|
|
130
|
+
|
|
131
|
+
rubycam xu dump # debug: status block as hex
|
|
132
|
+
rubycam xu send '16 02 02 00' --selector=0x06
|
|
133
|
+
```
|
|
134
|
+
|
|
135
|
+
Generic V4L2 commands default to `/dev/video0`; OBSBOT commands find the
|
|
136
|
+
camera by name. Both take `-d` to target a path, `/dev` name or card/bus
|
|
137
|
+
substring.
|
|
138
|
+
|
|
139
|
+
## Examples
|
|
140
|
+
|
|
141
|
+
```sh
|
|
142
|
+
ruby examples/snapshot.rb # capture snapshot.jpg
|
|
143
|
+
ruby examples/controls.rb # list all controls
|
|
144
|
+
ruby examples/controls.rb zoom_absolute 50
|
|
145
|
+
```
|
|
146
|
+
|
|
147
|
+
The library itself has no dependencies beyond the Ruby stdlib; only the
|
|
148
|
+
GTK viewer needs the dev shell and `bundle install`.
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
# Tiny4Linux feature checklist
|
|
2
|
+
|
|
3
|
+
Feature inventory of [Tiny4Linux](https://github.com/OpenFoxes/Tiny4Linux)
|
|
4
|
+
(Rust controller for the OBSBOT Tiny 2), tracked against Rubycam.
|
|
5
|
+
Boxes are checked as features land in Rubycam.
|
|
6
|
+
|
|
7
|
+
## Library (`tiny4linux` crate → `lib/rubycam/obsbot.rb`)
|
|
8
|
+
|
|
9
|
+
- [x] Open camera by hint: exact path, `/dev/<name>`, or scan `/dev/video*`
|
|
10
|
+
matching card / bus info, skipping metadata-capture nodes
|
|
11
|
+
- [x] Device info (card, bus)
|
|
12
|
+
- [x] Sleep / wake commands (command02 packets, selector 0x02)
|
|
13
|
+
- [x] Get status: sleep state (byte 0x02)
|
|
14
|
+
- [x] Get status: AI/tracking mode (bytes 0x18 + 0x1c, 10 modes + unknown)
|
|
15
|
+
- [x] Get status: HDR on/off (byte 0x06)
|
|
16
|
+
- [x] Get status: tracking speed (byte 0x21: 0=standard, 2=sport)
|
|
17
|
+
- [x] Set AI mode — 10 modes (selector 0x06, `16 02 m n`)
|
|
18
|
+
- [x] Set tracking speed — standard / sport (command02, selector 0x02)
|
|
19
|
+
- [x] Goto preset position 1–3 (command02 with float appendix, selector 0x02;
|
|
20
|
+
invalid preset raises)
|
|
21
|
+
- [x] Set HDR mode on/off (selector 0x06, `01 01 xx`)
|
|
22
|
+
- [x] Set exposure mode — manual / global / face (two-stage: command02
|
|
23
|
+
mode-type packet on 0x02, then `03 01 xx` on 0x06 for auto modes)
|
|
24
|
+
- [x] command02 packet builder (frame id `aa 25`, sequence nr, segment size
|
|
25
|
+
`0c 00`, checksum, function group, command, 16-byte appendix)
|
|
26
|
+
- [x] Send raw command bytes to unit 2, selector 0x02 or 0x06
|
|
27
|
+
- [x] Hex dump of current 0x06 and 0x02 state
|
|
28
|
+
- [x] Debug logging toggle (log sent commands / raw status)
|
|
29
|
+
|
|
30
|
+
## GUI (`t4l-gui` → `app/camera_app.rb`)
|
|
31
|
+
|
|
32
|
+
- [x] Sleep/wake control with state shown (icon + label follow camera state)
|
|
33
|
+
- [x] Current-status panel: sleep mode, AI mode, tracking speed, HDR, version
|
|
34
|
+
- [x] Preset position buttons 1–3 (tracking switched off before moving)
|
|
35
|
+
- [x] Tracking mode selector — all 10 AI modes, active mode highlighted
|
|
36
|
+
- [x] Tracking speed selector — standard / sport, active speed highlighted
|
|
37
|
+
- [x] HDR toggle button
|
|
38
|
+
- [x] Exposure mode buttons — manual / global / face
|
|
39
|
+
- [x] Dashboard ⇄ Widget (compact) window modes with toggle button
|
|
40
|
+
- [x] Periodic camera status poll; UI follows out-of-band changes
|
|
41
|
+
- [x] Camera hotplug: "no camera" message and automatic reconnect
|
|
42
|
+
- [x] Debug area: toggle debugging, send raw hex to 0x06 / 0x02,
|
|
43
|
+
hex-dump 0x06 / 0x02
|
|
44
|
+
- [ ] i18n (7 locales) — skipped: English only, like the rest of Rubycam
|
|
45
|
+
|
|
46
|
+
## CLI (`t4l` → `bin/rubycam`, dry-cli)
|
|
47
|
+
|
|
48
|
+
- [x] OBSBOT commands: status, wake/sleep, track, speed, preset, hdr, exposure
|
|
49
|
+
- [x] Raw extension-unit access: `xu dump`, `xu send` (with `--debug` logging)
|
|
50
|
+
- [x] V4L2 commands (any UVC camera): devices, info, controls, get/set/reset
|
|
51
|
+
- [x] Snapshot to JPEG with format negotiation
|
|
52
|
+
|
|
53
|
+
## Rubycam extras (not in Tiny4Linux)
|
|
54
|
+
|
|
55
|
+
- Live video preview (pure-Ruby V4L2 MJPG streaming)
|
|
56
|
+
- Generic V4L2 control sliders (pan/tilt/zoom/brightness/…) + reset
|
|
57
|
+
- Stream watchdog (rebuilds stalled stream after privacy sleep)
|
|
58
|
+
|
|
59
|
+
## Out of scope
|
|
60
|
+
|
|
61
|
+
- AUR packaging / desktop files / OBSBOT theme assets
|
|
62
|
+
|
|
63
|
+
## Firmware notes (verified on real hardware, 2026-07)
|
|
64
|
+
|
|
65
|
+
Tested against an actual OBSBOT Tiny 2 running newer firmware than the
|
|
66
|
+
Tiny4Linux captures:
|
|
67
|
+
|
|
68
|
+
- All set commands (sleep/wake, AI mode, speed, preset, HDR, exposure)
|
|
69
|
+
work byte-for-byte as Tiny4Linux sends them — AI tracking physically
|
|
70
|
+
confirmed.
|
|
71
|
+
- The 0x06 status block is **eventually consistent**: mode changes can take
|
|
72
|
+
seconds to appear (the GUI highlights optimistically and re-syncs on poll).
|
|
73
|
+
- Tracking speed is reported at byte 0x24, not 0x21 (which reads a constant
|
|
74
|
+
3 on this firmware); `Obsbot#status` checks both.
|
|
75
|
+
- Concurrent readers (two processes polling the extension unit) scramble
|
|
76
|
+
status reads — avoid running two control apps at once.
|
data/exe/rubycam
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
#!/usr/bin/env ruby
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
#
|
|
4
|
+
# Command-line control for Rubycam webcams.
|
|
5
|
+
#
|
|
6
|
+
# rubycam # list commands
|
|
7
|
+
# rubycam status # OBSBOT sleep/AI/HDR state
|
|
8
|
+
# rubycam wake # wake from privacy sleep
|
|
9
|
+
# rubycam track upper_body # AI tracking mode
|
|
10
|
+
# rubycam set zoom_absolute 50 # any V4L2 control
|
|
11
|
+
$LOAD_PATH.unshift File.expand_path("../lib", __dir__)
|
|
12
|
+
require "rubycam/cli"
|
|
13
|
+
|
|
14
|
+
Dry::CLI.new(Rubycam::CLI::Commands).call
|
data/lib/rubycam/cli.rb
ADDED
|
@@ -0,0 +1,324 @@
|
|
|
1
|
+
require 'dry/cli'
|
|
2
|
+
require_relative '../rubycam'
|
|
3
|
+
|
|
4
|
+
module Rubycam
|
|
5
|
+
# Command-line companion to the GTK viewer (bin/rubycam). Generic V4L2
|
|
6
|
+
# commands default to /dev/video0 like the GUI; OBSBOT vendor commands
|
|
7
|
+
# default to finding the camera by name.
|
|
8
|
+
module CLI
|
|
9
|
+
module Commands
|
|
10
|
+
extend Dry::CLI::Registry
|
|
11
|
+
|
|
12
|
+
# Base for commands that target one camera. Subclasses inherit the
|
|
13
|
+
# --device option (dry-cli copies params down the class hierarchy).
|
|
14
|
+
class VideoCommand < Dry::CLI::Command
|
|
15
|
+
option :device, type: :string, aliases: ['-d'], default: '/dev/video0',
|
|
16
|
+
desc: 'Device path, /dev name, or card/bus-name substring'
|
|
17
|
+
|
|
18
|
+
private
|
|
19
|
+
|
|
20
|
+
def with_device(options)
|
|
21
|
+
hint = options.fetch(:device)
|
|
22
|
+
device = Device.find(hint) or
|
|
23
|
+
abort "rubycam: no camera matches #{hint.inspect}"
|
|
24
|
+
begin
|
|
25
|
+
yield device
|
|
26
|
+
ensure
|
|
27
|
+
device.close
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def fetch_control(device, name)
|
|
32
|
+
device.controls.fetch(name.to_sym) do
|
|
33
|
+
abort "rubycam: unknown control #{name} (see `controls`)"
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def pick(value, allowed)
|
|
38
|
+
allowed.find { |a| a.to_s == value } or
|
|
39
|
+
abort "rubycam: expected one of: #{allowed.join(', ')}"
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
class ObsbotCommand < VideoCommand
|
|
44
|
+
option :device, type: :string, aliases: ['-d'], default: Obsbot::DEVICE_HINT,
|
|
45
|
+
desc: 'Device path, /dev name, or card/bus-name substring'
|
|
46
|
+
option :debug, type: :boolean, default: false,
|
|
47
|
+
desc: 'Log extension-unit traffic to stderr'
|
|
48
|
+
|
|
49
|
+
private
|
|
50
|
+
|
|
51
|
+
def with_obsbot(options)
|
|
52
|
+
with_device(options) do |device|
|
|
53
|
+
bot = Obsbot.new(device)
|
|
54
|
+
bot.debug = options[:debug]
|
|
55
|
+
yield bot
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
class Version < Dry::CLI::Command
|
|
61
|
+
desc 'Print version'
|
|
62
|
+
def call(**) = puts(VERSION)
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
class Devices < Dry::CLI::Command
|
|
66
|
+
desc 'List video capture devices'
|
|
67
|
+
|
|
68
|
+
def call(**)
|
|
69
|
+
Rubycam.devices.each do |dev|
|
|
70
|
+
meta = dev.device_caps & Device::CAP_META_CAPTURE != 0
|
|
71
|
+
puts format('%-14s %-28s %s%s', dev.path, dev.card, dev.bus_info,
|
|
72
|
+
meta ? ' (metadata)' : '')
|
|
73
|
+
dev.close
|
|
74
|
+
end
|
|
75
|
+
end
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
class Info < VideoCommand
|
|
79
|
+
desc 'Show device identity (driver, card, bus)'
|
|
80
|
+
|
|
81
|
+
def call(**options)
|
|
82
|
+
with_device(options) do |dev|
|
|
83
|
+
{ path: dev.path, driver: dev.driver, card: dev.card,
|
|
84
|
+
bus: dev.bus_info }.each { |k, v| puts format('%-8s %s', "#{k}:", v) }
|
|
85
|
+
end
|
|
86
|
+
end
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
class Controls < VideoCommand
|
|
90
|
+
desc 'List V4L2 controls with ranges and current values'
|
|
91
|
+
|
|
92
|
+
def call(**options)
|
|
93
|
+
with_device(options) do |dev|
|
|
94
|
+
dev.controls.each_value do |c|
|
|
95
|
+
notes = [(' [read-only]' if c.read_only?),
|
|
96
|
+
(' [inactive]' if c.inactive?)].compact.join
|
|
97
|
+
puts "#{c}#{notes}"
|
|
98
|
+
end
|
|
99
|
+
end
|
|
100
|
+
end
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
class Get < VideoCommand
|
|
104
|
+
desc 'Read one V4L2 control'
|
|
105
|
+
argument :control, required: true, desc: 'Control key (see `controls`)'
|
|
106
|
+
|
|
107
|
+
def call(control:, **options)
|
|
108
|
+
with_device(options) { |dev| puts fetch_control(dev, control).value }
|
|
109
|
+
end
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
class Set < VideoCommand
|
|
113
|
+
desc 'Set one V4L2 control'
|
|
114
|
+
argument :control, required: true, desc: 'Control key (see `controls`)'
|
|
115
|
+
argument :value, required: true, desc: 'Integer value (clamped to range)'
|
|
116
|
+
|
|
117
|
+
def call(control:, value:, **options)
|
|
118
|
+
with_device(options) do |dev|
|
|
119
|
+
ctrl = fetch_control(dev, control)
|
|
120
|
+
ctrl.value = Integer(value)
|
|
121
|
+
puts "#{ctrl.key} = #{ctrl.value}"
|
|
122
|
+
end
|
|
123
|
+
rescue ArgumentError
|
|
124
|
+
abort "rubycam: value must be an integer, got #{value.inspect}"
|
|
125
|
+
end
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
class Reset < VideoCommand
|
|
129
|
+
desc 'Reset all writable controls to their defaults'
|
|
130
|
+
|
|
131
|
+
def call(**options)
|
|
132
|
+
with_device(options) do |dev|
|
|
133
|
+
dev.controls.each_value do |c|
|
|
134
|
+
next if c.read_only? || c.type == :button
|
|
135
|
+
|
|
136
|
+
begin
|
|
137
|
+
c.value = c.default
|
|
138
|
+
rescue SystemCallError
|
|
139
|
+
# inactive controls (e.g. manual exposure in auto mode) reject writes
|
|
140
|
+
end
|
|
141
|
+
end
|
|
142
|
+
end
|
|
143
|
+
end
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
class Snapshot < VideoCommand
|
|
147
|
+
desc 'Capture a single frame to a JPEG file'
|
|
148
|
+
argument :path, desc: 'Output file (default: snapshot.jpg)'
|
|
149
|
+
option :width, type: :integer, default: 1920
|
|
150
|
+
option :height, type: :integer, default: 1080
|
|
151
|
+
|
|
152
|
+
def call(path: 'snapshot.jpg', **options)
|
|
153
|
+
with_device(options) do |dev|
|
|
154
|
+
dev.set_format(width: options.fetch(:width).to_i,
|
|
155
|
+
height: options.fetch(:height).to_i, pixel_format: 'MJPG')
|
|
156
|
+
File.binwrite(path, dev.capture_frame)
|
|
157
|
+
puts "#{dev.card}: wrote #{path} (#{dev.width}x#{dev.height} #{dev.pixel_format})"
|
|
158
|
+
end
|
|
159
|
+
end
|
|
160
|
+
end
|
|
161
|
+
|
|
162
|
+
# Nod (yes) and shake (no) gimbal gestures. The pan/tilt speed controls
|
|
163
|
+
# govern how fast absolute moves execute, so the gesture maxes them out
|
|
164
|
+
# for the duration — a slow drift doesn't read as a nod — and restores
|
|
165
|
+
# the saved speeds afterwards.
|
|
166
|
+
class GestureCommand < VideoCommand
|
|
167
|
+
SWING = 15 * 3600 # amplitude in V4L2 1/3600-degree units
|
|
168
|
+
BEAT = 0.5 # seconds per half-swing before reversing
|
|
169
|
+
|
|
170
|
+
private
|
|
171
|
+
|
|
172
|
+
def gesture(options, axis)
|
|
173
|
+
with_device(options) do |dev|
|
|
174
|
+
ctrl = fetch_control(dev, axis)
|
|
175
|
+
speeds = dev.controls.values_at(:pan_speed, :tilt_speed).compact
|
|
176
|
+
saved = speeds.map { |s| [s, s.value] }
|
|
177
|
+
begin
|
|
178
|
+
speeds.each { |s| s.value = s.max }
|
|
179
|
+
home = ctrl.value
|
|
180
|
+
2.times do
|
|
181
|
+
ctrl.value = home + SWING
|
|
182
|
+
sleep BEAT
|
|
183
|
+
ctrl.value = home - SWING
|
|
184
|
+
sleep BEAT
|
|
185
|
+
end
|
|
186
|
+
ctrl.value = home
|
|
187
|
+
sleep BEAT
|
|
188
|
+
ensure
|
|
189
|
+
saved.each { |s, v| s.value = v }
|
|
190
|
+
end
|
|
191
|
+
end
|
|
192
|
+
end
|
|
193
|
+
end
|
|
194
|
+
|
|
195
|
+
class Yes < GestureCommand
|
|
196
|
+
desc 'Nod the camera up and down, like nodding "yes"'
|
|
197
|
+
def call(**options) = gesture(options, :tilt_absolute)
|
|
198
|
+
end
|
|
199
|
+
|
|
200
|
+
class No < GestureCommand
|
|
201
|
+
desc 'Pan the camera side to side, like shaking your head "no"'
|
|
202
|
+
def call(**options) = gesture(options, :pan_absolute)
|
|
203
|
+
end
|
|
204
|
+
|
|
205
|
+
class Status < ObsbotCommand
|
|
206
|
+
desc 'Show OBSBOT status (sleep, AI mode, tracking speed, HDR)'
|
|
207
|
+
|
|
208
|
+
def call(**options)
|
|
209
|
+
with_obsbot(options) do |bot|
|
|
210
|
+
bot.status.each { |k, v| puts format('%-16s %s', "#{k}:", v) }
|
|
211
|
+
end
|
|
212
|
+
end
|
|
213
|
+
end
|
|
214
|
+
|
|
215
|
+
class Wake < ObsbotCommand
|
|
216
|
+
desc 'Wake the camera from privacy sleep'
|
|
217
|
+
def call(**options) = with_obsbot(options, &:wake!)
|
|
218
|
+
end
|
|
219
|
+
|
|
220
|
+
class Sleep < ObsbotCommand
|
|
221
|
+
desc 'Put the camera into privacy sleep'
|
|
222
|
+
def call(**options) = with_obsbot(options, &:sleep!)
|
|
223
|
+
end
|
|
224
|
+
|
|
225
|
+
class Track < ObsbotCommand
|
|
226
|
+
desc "Set AI tracking mode (#{Obsbot::AI_MODES.keys.join(', ')})"
|
|
227
|
+
argument :mode, required: true, desc: 'Tracking mode'
|
|
228
|
+
|
|
229
|
+
def call(mode:, **options)
|
|
230
|
+
with_obsbot(options) { |bot| bot.ai_mode = pick(mode, Obsbot::AI_MODES.keys) }
|
|
231
|
+
end
|
|
232
|
+
end
|
|
233
|
+
|
|
234
|
+
class Speed < ObsbotCommand
|
|
235
|
+
desc 'Set tracking speed (standard, sport)'
|
|
236
|
+
argument :speed, required: true, desc: 'Tracking speed'
|
|
237
|
+
|
|
238
|
+
def call(speed:, **options)
|
|
239
|
+
with_obsbot(options) do |bot|
|
|
240
|
+
bot.tracking_speed = pick(speed, Obsbot::TRACKING_SPEEDS)
|
|
241
|
+
end
|
|
242
|
+
end
|
|
243
|
+
end
|
|
244
|
+
|
|
245
|
+
class Hdr < ObsbotCommand
|
|
246
|
+
desc 'Switch HDR on or off'
|
|
247
|
+
argument :state, required: true, desc: 'on or off'
|
|
248
|
+
|
|
249
|
+
def call(state:, **options)
|
|
250
|
+
with_obsbot(options) { |bot| bot.hdr = pick(state, %i[on off]) == :on }
|
|
251
|
+
end
|
|
252
|
+
end
|
|
253
|
+
|
|
254
|
+
class Exposure < ObsbotCommand
|
|
255
|
+
desc 'Set exposure mode (manual, global, face)'
|
|
256
|
+
argument :mode, required: true, desc: 'Exposure mode'
|
|
257
|
+
|
|
258
|
+
def call(mode:, **options)
|
|
259
|
+
with_obsbot(options) do |bot|
|
|
260
|
+
bot.exposure_mode = pick(mode, Obsbot::EXPOSURE_MODES.keys)
|
|
261
|
+
end
|
|
262
|
+
end
|
|
263
|
+
end
|
|
264
|
+
|
|
265
|
+
class Preset < ObsbotCommand
|
|
266
|
+
desc 'Move gimbal to a preset position (tracking is switched off first)'
|
|
267
|
+
argument :number, required: true, desc: 'Preset number (1-3)'
|
|
268
|
+
|
|
269
|
+
def call(number:, **options)
|
|
270
|
+
n = Integer(number, exception: false)
|
|
271
|
+
abort 'rubycam: preset must be 1-3' unless n && (1..3).cover?(n)
|
|
272
|
+
with_obsbot(options) do |bot|
|
|
273
|
+
bot.ai_mode = :no_tracking
|
|
274
|
+
bot.goto_preset(n - 1)
|
|
275
|
+
end
|
|
276
|
+
end
|
|
277
|
+
end
|
|
278
|
+
|
|
279
|
+
class XuDump < ObsbotCommand
|
|
280
|
+
desc 'Hex-dump the 60-byte state of an extension-unit selector'
|
|
281
|
+
argument :selector, desc: 'Selector, e.g. 0x06 (default) or 0x02'
|
|
282
|
+
|
|
283
|
+
def call(selector: '0x06', **options)
|
|
284
|
+
with_obsbot(options) { |bot| puts bot.dump(Integer(selector)) }
|
|
285
|
+
end
|
|
286
|
+
end
|
|
287
|
+
|
|
288
|
+
class XuSend < ObsbotCommand
|
|
289
|
+
desc 'Send raw hex bytes to an extension-unit selector'
|
|
290
|
+
argument :hex, required: true, desc: "Hex bytes, e.g. '16 02 02 00'"
|
|
291
|
+
option :selector, default: '0x06', desc: 'Selector to send to'
|
|
292
|
+
|
|
293
|
+
def call(hex:, **options)
|
|
294
|
+
with_obsbot(options) do |bot|
|
|
295
|
+
bot.send_hex(hex, selector: Integer(options.fetch(:selector)))
|
|
296
|
+
end
|
|
297
|
+
end
|
|
298
|
+
end
|
|
299
|
+
|
|
300
|
+
register 'version', Version, aliases: ['-v', '--version']
|
|
301
|
+
register 'devices', Devices
|
|
302
|
+
register 'info', Info
|
|
303
|
+
register 'controls', Controls
|
|
304
|
+
register 'get', Get
|
|
305
|
+
register 'set', Set
|
|
306
|
+
register 'reset', Reset
|
|
307
|
+
register 'snapshot', Snapshot
|
|
308
|
+
register 'yes', Yes
|
|
309
|
+
register 'no', No
|
|
310
|
+
register 'status', Status
|
|
311
|
+
register 'wake', Wake
|
|
312
|
+
register 'sleep', Sleep
|
|
313
|
+
register 'track', Track
|
|
314
|
+
register 'speed', Speed
|
|
315
|
+
register 'hdr', Hdr
|
|
316
|
+
register 'exposure', Exposure
|
|
317
|
+
register 'preset', Preset
|
|
318
|
+
register 'xu' do |xu|
|
|
319
|
+
xu.register 'dump', XuDump
|
|
320
|
+
xu.register 'send', XuSend
|
|
321
|
+
end
|
|
322
|
+
end
|
|
323
|
+
end
|
|
324
|
+
end
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
module Rubycam
|
|
2
|
+
# A single camera control (brightness, zoom_absolute, ...) discovered via
|
|
3
|
+
# VIDIOC_QUERYCTRL. Reading and writing goes through the owning device.
|
|
4
|
+
class Control
|
|
5
|
+
TYPES = { 1 => :integer, 2 => :boolean, 3 => :menu, 4 => :button,
|
|
6
|
+
5 => :integer64, 6 => :ctrl_class, 7 => :string, 8 => :bitmask,
|
|
7
|
+
9 => :integer_menu }.freeze
|
|
8
|
+
|
|
9
|
+
FLAG_DISABLED = 0x0001
|
|
10
|
+
FLAG_GRABBED = 0x0002
|
|
11
|
+
FLAG_READ_ONLY = 0x0004
|
|
12
|
+
FLAG_INACTIVE = 0x0010
|
|
13
|
+
|
|
14
|
+
attr_reader :id, :type, :name, :min, :max, :step, :default, :flags
|
|
15
|
+
|
|
16
|
+
def initialize(device, id:, type:, name:, min:, max:, step:, default:, flags:)
|
|
17
|
+
@device = device
|
|
18
|
+
@id = id
|
|
19
|
+
@type = TYPES.fetch(type, type)
|
|
20
|
+
@name = name
|
|
21
|
+
@min = min
|
|
22
|
+
@max = max
|
|
23
|
+
@step = step
|
|
24
|
+
@default = default
|
|
25
|
+
@flags = flags
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
# Symbol key derived from the control name, e.g. "Zoom, Absolute" => :zoom_absolute
|
|
29
|
+
def key = @key ||= name.downcase.gsub(/[^a-z0-9]+/, '_').gsub(/^_|_$/, '').to_sym
|
|
30
|
+
|
|
31
|
+
def value = @device.get_control(id)
|
|
32
|
+
|
|
33
|
+
def value=(v)
|
|
34
|
+
clamped = v.clamp(min, max)
|
|
35
|
+
@device.set_control(id, clamped)
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def inactive? = flags & FLAG_INACTIVE != 0
|
|
39
|
+
def read_only? = flags & FLAG_READ_ONLY != 0
|
|
40
|
+
|
|
41
|
+
def to_s
|
|
42
|
+
"#{key} (#{type}) min=#{min} max=#{max} step=#{step} default=#{default} value=#{value}"
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
end
|
|
@@ -0,0 +1,267 @@
|
|
|
1
|
+
require 'fiddle'
|
|
2
|
+
|
|
3
|
+
module Rubycam
|
|
4
|
+
# A V4L2 capture device. Controls are read/written with ioctl; frames are
|
|
5
|
+
# streamed with memory-mapped kernel buffers (uvcvideo does not support
|
|
6
|
+
# plain read()).
|
|
7
|
+
class Device
|
|
8
|
+
BUF_TYPE_VIDEO_CAPTURE = 1
|
|
9
|
+
MEMORY_MMAP = 1
|
|
10
|
+
CTRL_FLAG_NEXT_CTRL = 0x80000000
|
|
11
|
+
|
|
12
|
+
PROT_READ = 1
|
|
13
|
+
MAP_SHARED = 1
|
|
14
|
+
|
|
15
|
+
LIBC = Fiddle.dlopen(nil)
|
|
16
|
+
MMAP = Fiddle::Function.new(
|
|
17
|
+
LIBC['mmap'],
|
|
18
|
+
[Fiddle::TYPE_VOIDP, Fiddle::TYPE_SIZE_T, Fiddle::TYPE_INT,
|
|
19
|
+
Fiddle::TYPE_INT, Fiddle::TYPE_INT, Fiddle::TYPE_LONG],
|
|
20
|
+
Fiddle::TYPE_VOIDP
|
|
21
|
+
)
|
|
22
|
+
MUNMAP = Fiddle::Function.new(
|
|
23
|
+
LIBC['munmap'], [Fiddle::TYPE_VOIDP, Fiddle::TYPE_SIZE_T], Fiddle::TYPE_INT
|
|
24
|
+
)
|
|
25
|
+
|
|
26
|
+
CAP_META_CAPTURE = 0x00800000
|
|
27
|
+
|
|
28
|
+
attr_reader :path, :driver, :card, :bus_info, :device_caps,
|
|
29
|
+
:width, :height, :pixel_format
|
|
30
|
+
|
|
31
|
+
# Find a camera by device path, /dev name, or a substring of its card
|
|
32
|
+
# name or bus info — e.g. Device.find('OBSBOT Tiny 2'). Metadata-only
|
|
33
|
+
# nodes (uvcvideo exposes one per camera) are skipped. Returns nil when
|
|
34
|
+
# nothing matches.
|
|
35
|
+
def self.find(hint)
|
|
36
|
+
[hint, "/dev/#{hint}"].each do |path|
|
|
37
|
+
return open(path) if File.exist?(path)
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
Dir['/dev/video*'].sort.each do |path|
|
|
41
|
+
device = begin
|
|
42
|
+
open(path)
|
|
43
|
+
rescue SystemCallError
|
|
44
|
+
next
|
|
45
|
+
end
|
|
46
|
+
if (device.card.include?(hint) || device.bus_info.include?(hint)) &&
|
|
47
|
+
device.device_caps & CAP_META_CAPTURE == 0
|
|
48
|
+
return device
|
|
49
|
+
end
|
|
50
|
+
device.close
|
|
51
|
+
end
|
|
52
|
+
nil
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def self.open(path = '/dev/video0')
|
|
56
|
+
device = new(path)
|
|
57
|
+
if block_given?
|
|
58
|
+
begin
|
|
59
|
+
yield device
|
|
60
|
+
ensure
|
|
61
|
+
device.close
|
|
62
|
+
end
|
|
63
|
+
else
|
|
64
|
+
device
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
def initialize(path)
|
|
69
|
+
@path = path
|
|
70
|
+
@io = File.open(path, 'r+')
|
|
71
|
+
@buffers = []
|
|
72
|
+
@streaming = false
|
|
73
|
+
query_capabilities
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
def close
|
|
77
|
+
stop_streaming
|
|
78
|
+
@io.close unless @io.closed?
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
# ---- Controls ----------------------------------------------------------
|
|
82
|
+
|
|
83
|
+
# All controls the driver exposes, keyed by symbol (e.g. :zoom_absolute).
|
|
84
|
+
def controls
|
|
85
|
+
@controls ||= enumerate_controls.to_h { |c| [c.key, c] }
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
def [](key) = controls.fetch(key).value
|
|
89
|
+
|
|
90
|
+
def []=(key, value)
|
|
91
|
+
controls.fetch(key).value = value
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
def get_control(id)
|
|
95
|
+
buf = [id, 0].pack('Ll')
|
|
96
|
+
@io.ioctl(Ioctl::VIDIOC_G_CTRL, buf)
|
|
97
|
+
buf.unpack('Ll')[1]
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
def set_control(id, value)
|
|
101
|
+
@io.ioctl(Ioctl::VIDIOC_S_CTRL, [id, value].pack('Ll'))
|
|
102
|
+
value
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
# ---- Format / frame rate -----------------------------------------------
|
|
106
|
+
|
|
107
|
+
def fourcc(str) = str.unpack1('V')
|
|
108
|
+
def fourcc_to_s(num) = [num].pack('V')
|
|
109
|
+
|
|
110
|
+
# Negotiate resolution and pixel format ('MJPG' or 'YUYV'). The driver may
|
|
111
|
+
# adjust the values; the actual result lands in width/height/pixel_format.
|
|
112
|
+
def set_format(width:, height:, pixel_format: 'MJPG')
|
|
113
|
+
buf = [BUF_TYPE_VIDEO_CAPTURE].pack('L') + "\0" * (Ioctl::FORMAT_SIZE - 4)
|
|
114
|
+
@io.ioctl(Ioctl::VIDIOC_G_FMT, buf)
|
|
115
|
+
buf[8, 12] = [width, height, fourcc(pixel_format)].pack('L3')
|
|
116
|
+
@io.ioctl(Ioctl::VIDIOC_S_FMT, buf)
|
|
117
|
+
@width, @height, pix = buf[8, 12].unpack('L3')
|
|
118
|
+
@pixel_format = fourcc_to_s(pix)
|
|
119
|
+
@frame_size = buf[28, 4].unpack1('L')
|
|
120
|
+
[@width, @height, @pixel_format]
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
def set_fps(fps)
|
|
124
|
+
buf = [BUF_TYPE_VIDEO_CAPTURE].pack('L') + "\0" * (Ioctl::STREAMPARM_SIZE - 4)
|
|
125
|
+
@io.ioctl(Ioctl::VIDIOC_G_PARM, buf)
|
|
126
|
+
buf[12, 8] = [1, fps].pack('L2')
|
|
127
|
+
@io.ioctl(Ioctl::VIDIOC_S_PARM, buf)
|
|
128
|
+
num, denom = buf[12, 8].unpack('L2')
|
|
129
|
+
denom / num.to_f
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
# ---- Streaming -----------------------------------------------------------
|
|
133
|
+
|
|
134
|
+
def start_streaming(buffer_count: 4)
|
|
135
|
+
set_format(width: 1920, height: 1080) unless @width
|
|
136
|
+
request_buffers(buffer_count)
|
|
137
|
+
map_buffers
|
|
138
|
+
@buffers.each_index { |i| queue_buffer(i) }
|
|
139
|
+
@io.ioctl(Ioctl::VIDIOC_STREAMON, [BUF_TYPE_VIDEO_CAPTURE].pack('L'))
|
|
140
|
+
@streaming = true
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
def stop_streaming
|
|
144
|
+
return unless @streaming
|
|
145
|
+
|
|
146
|
+
@io.ioctl(Ioctl::VIDIOC_STREAMOFF, [BUF_TYPE_VIDEO_CAPTURE].pack('L'))
|
|
147
|
+
@buffers.each { |b| MUNMAP.call(b[:ptr], b[:length]) }
|
|
148
|
+
@buffers.clear
|
|
149
|
+
request_buffers(0)
|
|
150
|
+
@streaming = false
|
|
151
|
+
end
|
|
152
|
+
|
|
153
|
+
def streaming? = @streaming
|
|
154
|
+
|
|
155
|
+
# Tear down and rebuild the buffer queue. Useful when a stream goes
|
|
156
|
+
# quiet after an external event (e.g. a camera's privacy sleep).
|
|
157
|
+
def restart_streaming
|
|
158
|
+
stop_streaming
|
|
159
|
+
start_streaming
|
|
160
|
+
end
|
|
161
|
+
|
|
162
|
+
# Block until the next frame is ready and return its bytes as a String.
|
|
163
|
+
# The default timeout is generous because the camera's ISP takes a few
|
|
164
|
+
# seconds to deliver the first frame after STREAMON.
|
|
165
|
+
def capture_frame(timeout: 10.0)
|
|
166
|
+
start_streaming unless @streaming
|
|
167
|
+
IO.select([@io], nil, nil, timeout) or raise "timed out waiting for frame from #{path}"
|
|
168
|
+
buf = dequeue_buffer
|
|
169
|
+
frame = @buffers[buf[:index]][:ptr][0, buf[:bytesused]]
|
|
170
|
+
queue_buffer(buf[:index])
|
|
171
|
+
frame
|
|
172
|
+
end
|
|
173
|
+
|
|
174
|
+
# Non-blocking: return the next frame if one is ready, else nil.
|
|
175
|
+
# Suited to GUI main loops that tick faster than the camera delivers.
|
|
176
|
+
def poll_frame
|
|
177
|
+
start_streaming unless @streaming
|
|
178
|
+
IO.select([@io], nil, nil, 0) or return nil
|
|
179
|
+
buf = dequeue_buffer
|
|
180
|
+
frame = @buffers[buf[:index]][:ptr][0, buf[:bytesused]]
|
|
181
|
+
queue_buffer(buf[:index])
|
|
182
|
+
frame
|
|
183
|
+
end
|
|
184
|
+
|
|
185
|
+
def each_frame
|
|
186
|
+
start_streaming unless @streaming
|
|
187
|
+
loop { yield capture_frame }
|
|
188
|
+
end
|
|
189
|
+
|
|
190
|
+
def to_io = @io
|
|
191
|
+
|
|
192
|
+
private
|
|
193
|
+
|
|
194
|
+
def query_capabilities
|
|
195
|
+
buf = "\0" * Ioctl::CAPABILITY_SIZE
|
|
196
|
+
@io.ioctl(Ioctl::VIDIOC_QUERYCAP, buf)
|
|
197
|
+
@driver = buf[0, 16].unpack1('Z16')
|
|
198
|
+
@card = buf[16, 32].unpack1('Z32')
|
|
199
|
+
@bus_info = buf[48, 32].unpack1('Z32')
|
|
200
|
+
@device_caps = buf[88, 4].unpack1('L')
|
|
201
|
+
end
|
|
202
|
+
|
|
203
|
+
def enumerate_controls
|
|
204
|
+
found = []
|
|
205
|
+
id = CTRL_FLAG_NEXT_CTRL
|
|
206
|
+
loop do
|
|
207
|
+
buf = [id].pack('L') + "\0" * (Ioctl::QUERYCTRL_SIZE - 4)
|
|
208
|
+
begin
|
|
209
|
+
@io.ioctl(Ioctl::VIDIOC_QUERYCTRL, buf)
|
|
210
|
+
rescue Errno::EINVAL
|
|
211
|
+
break
|
|
212
|
+
end
|
|
213
|
+
ctrl_id, type = buf.unpack('L2')
|
|
214
|
+
name = buf[8, 32].unpack1('Z32')
|
|
215
|
+
min, max, step, default = buf[40, 16].unpack('l4')
|
|
216
|
+
flags = buf[56, 4].unpack1('L')
|
|
217
|
+
ctrl = Control.new(self, id: ctrl_id, type:, name:, min:, max:,
|
|
218
|
+
step:, default:, flags:)
|
|
219
|
+
found << ctrl unless ctrl.type == :ctrl_class || flags & Control::FLAG_DISABLED != 0
|
|
220
|
+
id = ctrl_id | CTRL_FLAG_NEXT_CTRL
|
|
221
|
+
end
|
|
222
|
+
found
|
|
223
|
+
end
|
|
224
|
+
|
|
225
|
+
def request_buffers(count)
|
|
226
|
+
buf = [count, BUF_TYPE_VIDEO_CAPTURE, MEMORY_MMAP].pack('L3') +
|
|
227
|
+
"\0" * (Ioctl::REQUESTBUFFERS_SIZE - 12)
|
|
228
|
+
@io.ioctl(Ioctl::VIDIOC_REQBUFS, buf)
|
|
229
|
+
buf.unpack1('L')
|
|
230
|
+
end
|
|
231
|
+
|
|
232
|
+
def buffer_struct(index)
|
|
233
|
+
[index, BUF_TYPE_VIDEO_CAPTURE].pack('L2') +
|
|
234
|
+
"\0" * 52 + [MEMORY_MMAP].pack('L') + "\0" * (Ioctl::BUFFER_SIZE - 64)
|
|
235
|
+
end
|
|
236
|
+
|
|
237
|
+
def parse_buffer(buf)
|
|
238
|
+
index, _type, bytesused = buf.unpack('L3')
|
|
239
|
+
offset = buf[64, 4].unpack1('L')
|
|
240
|
+
length = buf[72, 4].unpack1('L')
|
|
241
|
+
{ index:, bytesused:, offset:, length: }
|
|
242
|
+
end
|
|
243
|
+
|
|
244
|
+
def map_buffers
|
|
245
|
+
count = request_buffers(4)
|
|
246
|
+
@buffers = count.times.map do |i|
|
|
247
|
+
struct = buffer_struct(i)
|
|
248
|
+
@io.ioctl(Ioctl::VIDIOC_QUERYBUF, struct)
|
|
249
|
+
info = parse_buffer(struct)
|
|
250
|
+
ptr = MMAP.call(nil, info[:length], PROT_READ, MAP_SHARED, @io.fileno, info[:offset])
|
|
251
|
+
raise "mmap failed for buffer #{i}" if ptr.to_i == -1 || ptr.to_i == 2**64 - 1
|
|
252
|
+
|
|
253
|
+
{ ptr: Fiddle::Pointer.new(ptr.to_i), length: info[:length] }
|
|
254
|
+
end
|
|
255
|
+
end
|
|
256
|
+
|
|
257
|
+
def queue_buffer(index)
|
|
258
|
+
@io.ioctl(Ioctl::VIDIOC_QBUF, buffer_struct(index))
|
|
259
|
+
end
|
|
260
|
+
|
|
261
|
+
def dequeue_buffer
|
|
262
|
+
struct = buffer_struct(0)
|
|
263
|
+
@io.ioctl(Ioctl::VIDIOC_DQBUF, struct)
|
|
264
|
+
parse_buffer(struct)
|
|
265
|
+
end
|
|
266
|
+
end
|
|
267
|
+
end
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
# V4L2 ioctl plumbing: request-code computation and struct layouts.
|
|
2
|
+
#
|
|
3
|
+
# ioctl request codes follow the kernel's _IOC macro:
|
|
4
|
+
# dir(2 bits) | size(14 bits) | type(8 bits) | nr(8 bits)
|
|
5
|
+
# Struct sizes below must match the C layouts in <linux/videodev2.h>
|
|
6
|
+
# on 64-bit; each SIZE constant is asserted against its pack template.
|
|
7
|
+
module Rubycam
|
|
8
|
+
module Ioctl
|
|
9
|
+
NONE = 0
|
|
10
|
+
WRITE = 1
|
|
11
|
+
READ = 2
|
|
12
|
+
|
|
13
|
+
def self.ioc(dir, type, nr, size) = (dir << 30) | (size << 16) | (type.ord << 8) | nr
|
|
14
|
+
def self.iowr(type, nr, size) = ioc(WRITE | READ, type, nr, size)
|
|
15
|
+
def self.ior(type, nr, size) = ioc(READ, type, nr, size)
|
|
16
|
+
def self.iow(type, nr, size) = ioc(WRITE, type, nr, size)
|
|
17
|
+
|
|
18
|
+
# struct v4l2_capability { u8 driver[16]; u8 card[32]; u8 bus_info[32];
|
|
19
|
+
# u32 version; u32 capabilities; u32 device_caps; u32 reserved[3]; }
|
|
20
|
+
CAPABILITY_SIZE = 104
|
|
21
|
+
|
|
22
|
+
# struct v4l2_queryctrl { u32 id; u32 type; u8 name[32]; s32 min; s32 max;
|
|
23
|
+
# s32 step; s32 default; u32 flags; u32 reserved[2]; }
|
|
24
|
+
QUERYCTRL_SIZE = 68
|
|
25
|
+
|
|
26
|
+
# struct v4l2_control { u32 id; s32 value; }
|
|
27
|
+
CONTROL_SIZE = 8
|
|
28
|
+
|
|
29
|
+
# struct v4l2_format { u32 type; u8 pad[4]; union fmt[200]; } (8-aligned union)
|
|
30
|
+
FORMAT_SIZE = 208
|
|
31
|
+
|
|
32
|
+
# struct v4l2_requestbuffers { u32 count; u32 type; u32 memory;
|
|
33
|
+
# u32 capabilities; u8 flags; u8 reserved[3]; }
|
|
34
|
+
REQUESTBUFFERS_SIZE = 20
|
|
35
|
+
|
|
36
|
+
# struct v4l2_buffer (64-bit): u32 index,type,bytesused,flags,field; pad4;
|
|
37
|
+
# timeval(16); v4l2_timecode(16); u32 sequence,memory; union m(8);
|
|
38
|
+
# u32 length,reserved2,request_fd; pad4;
|
|
39
|
+
BUFFER_SIZE = 88
|
|
40
|
+
|
|
41
|
+
# struct v4l2_streamparm { u32 type; union parm[200]; }
|
|
42
|
+
STREAMPARM_SIZE = 204
|
|
43
|
+
|
|
44
|
+
V = 'V'
|
|
45
|
+
VIDIOC_QUERYCAP = ior(V, 0, CAPABILITY_SIZE)
|
|
46
|
+
VIDIOC_G_FMT = iowr(V, 4, FORMAT_SIZE)
|
|
47
|
+
VIDIOC_S_FMT = iowr(V, 5, FORMAT_SIZE)
|
|
48
|
+
VIDIOC_REQBUFS = iowr(V, 8, REQUESTBUFFERS_SIZE)
|
|
49
|
+
VIDIOC_QUERYBUF = iowr(V, 9, BUFFER_SIZE)
|
|
50
|
+
VIDIOC_QBUF = iowr(V, 15, BUFFER_SIZE)
|
|
51
|
+
VIDIOC_DQBUF = iowr(V, 17, BUFFER_SIZE)
|
|
52
|
+
VIDIOC_STREAMON = iow(V, 18, 4)
|
|
53
|
+
VIDIOC_STREAMOFF = iow(V, 19, 4)
|
|
54
|
+
VIDIOC_G_PARM = iowr(V, 21, STREAMPARM_SIZE)
|
|
55
|
+
VIDIOC_S_PARM = iowr(V, 22, STREAMPARM_SIZE)
|
|
56
|
+
VIDIOC_G_CTRL = iowr(V, 27, CONTROL_SIZE)
|
|
57
|
+
VIDIOC_S_CTRL = iowr(V, 28, CONTROL_SIZE)
|
|
58
|
+
VIDIOC_QUERYCTRL = iowr(V, 36, QUERYCTRL_SIZE)
|
|
59
|
+
end
|
|
60
|
+
end
|
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
module Rubycam
|
|
2
|
+
# OBSBOT vendor commands, spoken over the camera's UVC Extension Unit
|
|
3
|
+
# (unit 2, GUID 9a1e7291-6843-4683-6d92-39bc7906ee49) via the uvcvideo
|
|
4
|
+
# driver's UVCIOC_CTRL_QUERY ioctl. Protocol reverse-engineered by the
|
|
5
|
+
# Tiny4Linux project (https://github.com/OpenFoxes/Tiny4Linux).
|
|
6
|
+
class Obsbot
|
|
7
|
+
UNIT = 0x02
|
|
8
|
+
SELECTOR_COMMAND = 0x02
|
|
9
|
+
SELECTOR_STATUS = 0x06
|
|
10
|
+
PAYLOAD_SIZE = 60
|
|
11
|
+
|
|
12
|
+
UVC_SET_CUR = 0x01
|
|
13
|
+
UVC_GET_CUR = 0x81
|
|
14
|
+
|
|
15
|
+
# struct uvc_xu_control_query { u8 unit; u8 selector; u8 query;
|
|
16
|
+
# u16 size; u8 *data; } — 16 bytes with padding on 64-bit.
|
|
17
|
+
UVCIOC_CTRL_QUERY = Ioctl.iowr('u', 0x21, 16)
|
|
18
|
+
|
|
19
|
+
DEVICE_HINT = 'OBSBOT Tiny 2'.freeze
|
|
20
|
+
|
|
21
|
+
# Command packets sent to selector 0x02:
|
|
22
|
+
# aa 25 | seq(2) | 0c 00 | checksum(2) | group(6) | cmd(6) | appendix(16)
|
|
23
|
+
# Sequence numbers and checksums are replayed verbatim from captures of
|
|
24
|
+
# the official software; the camera accepts them as-is.
|
|
25
|
+
def self.command02(seq:, checksum:, group:, cmd:, appendix: [0] * 16)
|
|
26
|
+
([0xaa, 0x25] + seq + [0x0c, 0x00] + checksum + group + cmd + appendix)
|
|
27
|
+
.pack('C*')
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
GROUP_SLEEP = [0x0a, 0x02, 0xc2, 0xa0, 0x04, 0x00].freeze
|
|
31
|
+
WAKE_PACKET = command02(seq: [0xa5, 0x00], checksum: [0x5f, 0xef],
|
|
32
|
+
group: GROUP_SLEEP,
|
|
33
|
+
cmd: [0xbe, 0x07, 0x00, 0x00, 0x00, 0x00])
|
|
34
|
+
SLEEP_PACKET = command02(seq: [0x42, 0x00], checksum: [0xea, 0x63],
|
|
35
|
+
group: GROUP_SLEEP,
|
|
36
|
+
cmd: [0xbf, 0xfb, 0x01, 0x00, 0x00, 0x00])
|
|
37
|
+
|
|
38
|
+
GROUP_TRACKING_SPEED = [0x0a, 0x04, 0xc4, 0x0c, 0x01, 0x00].freeze
|
|
39
|
+
TRACKING_SPEED_PACKETS = {
|
|
40
|
+
standard: command02(seq: [0x20, 0x00], checksum: [0xab, 0xcb],
|
|
41
|
+
group: GROUP_TRACKING_SPEED,
|
|
42
|
+
cmd: [0xe6, 0x3f, 0x00, 0x00, 0x00, 0x00]),
|
|
43
|
+
sport: command02(seq: [0x21, 0x00], checksum: [0xfa, 0x0e],
|
|
44
|
+
group: GROUP_TRACKING_SPEED,
|
|
45
|
+
cmd: [0x67, 0xfe, 0x02, 0x00, 0x00, 0x00])
|
|
46
|
+
}.freeze
|
|
47
|
+
|
|
48
|
+
GROUP_PRESETS = [0x0a, 0x04, 0xc4, 0x39, 0x14, 0x00].freeze
|
|
49
|
+
PRESET_APPENDIX = ([0x00, 0x00, 0x80, 0x3f] * 4).freeze # 1.0f × 4
|
|
50
|
+
PRESET_PACKETS = [
|
|
51
|
+
command02(seq: [0x20, 0x00], checksum: [0x6b, 0xdc], group: GROUP_PRESETS,
|
|
52
|
+
cmd: [0xd6, 0xfb, 0x00, 0x00, 0x00, 0x00], appendix: PRESET_APPENDIX),
|
|
53
|
+
command02(seq: [0x1a, 0x00], checksum: [0x4b, 0x03], group: GROUP_PRESETS,
|
|
54
|
+
cmd: [0xeb, 0x2a, 0x01, 0x00, 0x00, 0x00], appendix: PRESET_APPENDIX),
|
|
55
|
+
command02(seq: [0x26, 0x00], checksum: [0x8b, 0xc3], group: GROUP_PRESETS,
|
|
56
|
+
cmd: [0xaf, 0x19, 0x02, 0x00, 0x00, 0x00], appendix: PRESET_APPENDIX)
|
|
57
|
+
].freeze
|
|
58
|
+
|
|
59
|
+
# Switching exposure mode is two-stage: a mode-type packet on 0x02
|
|
60
|
+
# (manual vs. automatic), then for the automatic flavours a follow-up
|
|
61
|
+
# on 0x06 choosing global or face metering.
|
|
62
|
+
GROUP_EXPOSURE_TYPE = [0x0a, 0x02, 0x82, 0x29, 0x05, 0x00].freeze
|
|
63
|
+
EXPOSURE_TYPE_PACKETS = {
|
|
64
|
+
manual: command02(seq: [0x16, 0x00], checksum: [0x58, 0x91],
|
|
65
|
+
group: GROUP_EXPOSURE_TYPE,
|
|
66
|
+
cmd: [0xb2, 0xaf, 0x02, 0x04, 0x00, 0x00]),
|
|
67
|
+
auto: command02(seq: [0x15, 0x00], checksum: [0xa8, 0x9e],
|
|
68
|
+
group: GROUP_EXPOSURE_TYPE,
|
|
69
|
+
cmd: [0xf9, 0x27, 0x01, 0x32, 0x00, 0x00])
|
|
70
|
+
}.freeze
|
|
71
|
+
EXPOSURE_MODES = { manual: nil, global: [0x03, 0x01, 0x00],
|
|
72
|
+
face: [0x03, 0x01, 0x01] }.freeze
|
|
73
|
+
|
|
74
|
+
# AI tracking modes, keyed by symbol; values are the two mode bytes as
|
|
75
|
+
# sent in the set command and reported at 0x18/0x1c of the status block.
|
|
76
|
+
AI_MODES = { no_tracking: [0, 0], normal_tracking: [2, 0],
|
|
77
|
+
upper_body: [2, 1], close_up: [2, 2], headless: [2, 3],
|
|
78
|
+
lower_body: [2, 4], desk_mode: [5, 0], whiteboard: [4, 0],
|
|
79
|
+
hand: [6, 0], group: [1, 0] }.freeze
|
|
80
|
+
AI_MODE_BY_BYTES = AI_MODES.invert.freeze
|
|
81
|
+
|
|
82
|
+
TRACKING_SPEEDS = %i[standard sport].freeze
|
|
83
|
+
|
|
84
|
+
# If set, sent commands and raw status reads are logged to stderr.
|
|
85
|
+
attr_accessor :debug
|
|
86
|
+
|
|
87
|
+
def initialize(device)
|
|
88
|
+
@device = device
|
|
89
|
+
@debug = false
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
def wake! = send_command(WAKE_PACKET)
|
|
93
|
+
def sleep! = send_command(SLEEP_PACKET)
|
|
94
|
+
|
|
95
|
+
def ai_mode=(mode)
|
|
96
|
+
m, n = AI_MODES.fetch(mode)
|
|
97
|
+
send_status_command([0x16, 0x02, m, n].pack('C*'))
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
def tracking_speed=(speed)
|
|
101
|
+
send_command(TRACKING_SPEED_PACKETS.fetch(speed))
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
# Move the gimbal to a stored preset position (0..2). The camera ignores
|
|
105
|
+
# this while tracking, so callers normally switch to :no_tracking first.
|
|
106
|
+
def goto_preset(number)
|
|
107
|
+
send_command(PRESET_PACKETS.fetch(number))
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
def hdr=(on)
|
|
111
|
+
send_status_command([0x01, 0x01, on ? 1 : 0].pack('C*'))
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
def exposure_mode=(mode)
|
|
115
|
+
metering = EXPOSURE_MODES.fetch(mode)
|
|
116
|
+
send_command(EXPOSURE_TYPE_PACKETS.fetch(metering ? :auto : :manual))
|
|
117
|
+
send_status_command(metering.pack('C*')) if metering
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
# NOTE: on newer Tiny 2 firmware the status block can lag behind mode
|
|
121
|
+
# changes by several seconds; treat it as eventually consistent.
|
|
122
|
+
def status
|
|
123
|
+
raw_status.then do |bytes|
|
|
124
|
+
{ asleep: bytes[0x02] == 1,
|
|
125
|
+
hdr: bytes[0x06] != 0,
|
|
126
|
+
ai_mode: AI_MODE_BY_BYTES.fetch([bytes[0x18], bytes[0x1c]], :unknown),
|
|
127
|
+
tracking_speed: decode_speed(bytes) }
|
|
128
|
+
end
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
def asleep? = status[:asleep]
|
|
132
|
+
|
|
133
|
+
# ---- Debug console -------------------------------------------------------
|
|
134
|
+
# Raw access to the extension unit, mirroring Tiny4Linux's debug area.
|
|
135
|
+
|
|
136
|
+
def send_hex(hex, selector: SELECTOR_STATUS)
|
|
137
|
+
send_to(selector, [hex.gsub(/\s/, '')].pack('H*'))
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
# Current 60-byte state of a selector as a hex string.
|
|
141
|
+
def dump(selector = SELECTOR_STATUS)
|
|
142
|
+
xu_query(UVC_GET_CUR, selector).unpack1('H*')
|
|
143
|
+
end
|
|
144
|
+
|
|
145
|
+
private
|
|
146
|
+
|
|
147
|
+
# Tiny4Linux reads speed at 0x21 (0=standard, 2=sport), but newer Tiny 2
|
|
148
|
+
# firmware keeps a constant 3 there and reports the speed at 0x24.
|
|
149
|
+
def decode_speed(bytes)
|
|
150
|
+
[bytes[0x21], bytes[0x24]].find { |b| [0, 2].include?(b) } == 2 ? :sport : :standard
|
|
151
|
+
end
|
|
152
|
+
|
|
153
|
+
def send_command(packet) = send_to(SELECTOR_COMMAND, packet)
|
|
154
|
+
def send_status_command(packet) = send_to(SELECTOR_STATUS, packet)
|
|
155
|
+
|
|
156
|
+
def send_to(selector, packet)
|
|
157
|
+
warn format('obsbot -> 0x%02x: %s', selector, packet.unpack1('H*')) if debug
|
|
158
|
+
xu_query(UVC_SET_CUR, selector, packet)
|
|
159
|
+
true
|
|
160
|
+
end
|
|
161
|
+
|
|
162
|
+
def raw_status
|
|
163
|
+
xu_query(UVC_GET_CUR, SELECTOR_STATUS).tap do |raw|
|
|
164
|
+
warn "obsbot <- status: #{raw.unpack1('H*')}" if debug
|
|
165
|
+
end.bytes
|
|
166
|
+
end
|
|
167
|
+
|
|
168
|
+
def xu_query(query, selector, payload = '')
|
|
169
|
+
buffer = Fiddle::Pointer.malloc(PAYLOAD_SIZE, Fiddle::RUBY_FREE)
|
|
170
|
+
buffer[0, payload.bytesize] = payload unless payload.empty?
|
|
171
|
+
request = [UNIT, selector, query, PAYLOAD_SIZE, buffer.to_i].pack('C3xvx2Q')
|
|
172
|
+
@device.to_io.ioctl(UVCIOC_CTRL_QUERY, request)
|
|
173
|
+
buffer[0, PAYLOAD_SIZE]
|
|
174
|
+
end
|
|
175
|
+
end
|
|
176
|
+
end
|
data/lib/rubycam.rb
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
# Rubycam: pure-Ruby V4L2 webcam library (controls + MJPG/YUYV capture).
|
|
2
|
+
#
|
|
3
|
+
# Rubycam::Device.open('/dev/video0') do |cam|
|
|
4
|
+
# cam[:zoom_absolute] = 50
|
|
5
|
+
# cam.set_format(width: 1920, height: 1080, pixel_format: 'MJPG')
|
|
6
|
+
# File.binwrite('frame.jpg', cam.capture_frame)
|
|
7
|
+
# end
|
|
8
|
+
require_relative 'rubycam/version'
|
|
9
|
+
require_relative 'rubycam/ioctl'
|
|
10
|
+
require_relative 'rubycam/controls'
|
|
11
|
+
require_relative 'rubycam/device'
|
|
12
|
+
require_relative 'rubycam/obsbot'
|
|
13
|
+
|
|
14
|
+
module Rubycam
|
|
15
|
+
# All /dev/video* nodes that are actual capture devices.
|
|
16
|
+
def self.devices
|
|
17
|
+
Dir['/dev/video*'].sort.filter_map do |path|
|
|
18
|
+
Device.open(path)
|
|
19
|
+
rescue SystemCallError
|
|
20
|
+
nil
|
|
21
|
+
end
|
|
22
|
+
end
|
|
23
|
+
end
|
data/rubycam.gemspec
ADDED
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "lib/rubycam/version"
|
|
4
|
+
|
|
5
|
+
Gem::Specification.new do |spec|
|
|
6
|
+
spec.name = "rubycam"
|
|
7
|
+
spec.version = Rubycam::VERSION
|
|
8
|
+
spec.authors = ["Nathan Kidd"]
|
|
9
|
+
spec.email = ["nathankidd@hey.com"]
|
|
10
|
+
|
|
11
|
+
spec.summary = "Pure-Ruby V4L2 webcam library and CLI, with OBSBOT Tiny support"
|
|
12
|
+
|
|
13
|
+
spec.description = <<~DESC
|
|
14
|
+
Rubycam is a pure-Ruby V4L2 library for controlling webcams and capturing
|
|
15
|
+
MJPG/YUYV frames, plus a dry-cli command-line tool. It includes a vendor
|
|
16
|
+
extension driver for OBSBOT Tiny cameras: privacy sleep/wake, AI tracking
|
|
17
|
+
modes, gimbal presets, tracking speed, HDR and exposure control. The GTK4
|
|
18
|
+
desktop viewer ships separately as the rubycam-gtk gem.
|
|
19
|
+
DESC
|
|
20
|
+
|
|
21
|
+
spec.homepage = "https://github.com/ruby-gtk-project/rubycam"
|
|
22
|
+
spec.license = "MIT"
|
|
23
|
+
spec.required_ruby_version = ">= 3.1.0"
|
|
24
|
+
|
|
25
|
+
spec.metadata["source_code_uri"] = spec.homepage
|
|
26
|
+
spec.metadata["changelog_uri"] = "#{spec.homepage}/releases"
|
|
27
|
+
spec.metadata["rubygems_mfa_required"] = "true"
|
|
28
|
+
|
|
29
|
+
# The rubycam gem is the library + CLI. The GTK viewer (lib/rubycam/gtk)
|
|
30
|
+
# is the separate rubycam-gtk gem; dev-only files are left out.
|
|
31
|
+
spec.files = `git ls-files -z`.split("\x0").select do |f|
|
|
32
|
+
(f.start_with?("lib/rubycam") && !f.start_with?("lib/rubycam/gtk") && !f.end_with?(".erb")) ||
|
|
33
|
+
f == "exe/rubycam" ||
|
|
34
|
+
f == "rubycam.gemspec" ||
|
|
35
|
+
%w[README.md LICENSE TINY4LINUX_FEATURES.md].include?(f)
|
|
36
|
+
end
|
|
37
|
+
spec.bindir = "exe"
|
|
38
|
+
spec.executables = ["rubycam"]
|
|
39
|
+
spec.require_paths = ["lib"]
|
|
40
|
+
|
|
41
|
+
spec.add_dependency "dry-cli", "~> 1.0"
|
|
42
|
+
|
|
43
|
+
spec.add_development_dependency "minitest", "~> 5.0"
|
|
44
|
+
spec.add_development_dependency "rake", "~> 13.0"
|
|
45
|
+
spec.add_development_dependency "rubocop", "~> 1.21"
|
|
46
|
+
spec.add_development_dependency "lefthook", "~> 2.1"
|
|
47
|
+
end
|
metadata
ADDED
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
--- !ruby/object:Gem::Specification
|
|
2
|
+
name: rubycam
|
|
3
|
+
version: !ruby/object:Gem::Version
|
|
4
|
+
version: 0.1.1
|
|
5
|
+
platform: ruby
|
|
6
|
+
authors:
|
|
7
|
+
- Nathan Kidd
|
|
8
|
+
bindir: exe
|
|
9
|
+
cert_chain: []
|
|
10
|
+
date: 1980-01-01 00:00:00.000000000 Z
|
|
11
|
+
dependencies:
|
|
12
|
+
- !ruby/object:Gem::Dependency
|
|
13
|
+
name: dry-cli
|
|
14
|
+
requirement: !ruby/object:Gem::Requirement
|
|
15
|
+
requirements:
|
|
16
|
+
- - "~>"
|
|
17
|
+
- !ruby/object:Gem::Version
|
|
18
|
+
version: '1.0'
|
|
19
|
+
type: :runtime
|
|
20
|
+
prerelease: false
|
|
21
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
22
|
+
requirements:
|
|
23
|
+
- - "~>"
|
|
24
|
+
- !ruby/object:Gem::Version
|
|
25
|
+
version: '1.0'
|
|
26
|
+
- !ruby/object:Gem::Dependency
|
|
27
|
+
name: minitest
|
|
28
|
+
requirement: !ruby/object:Gem::Requirement
|
|
29
|
+
requirements:
|
|
30
|
+
- - "~>"
|
|
31
|
+
- !ruby/object:Gem::Version
|
|
32
|
+
version: '5.0'
|
|
33
|
+
type: :development
|
|
34
|
+
prerelease: false
|
|
35
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
36
|
+
requirements:
|
|
37
|
+
- - "~>"
|
|
38
|
+
- !ruby/object:Gem::Version
|
|
39
|
+
version: '5.0'
|
|
40
|
+
- !ruby/object:Gem::Dependency
|
|
41
|
+
name: rake
|
|
42
|
+
requirement: !ruby/object:Gem::Requirement
|
|
43
|
+
requirements:
|
|
44
|
+
- - "~>"
|
|
45
|
+
- !ruby/object:Gem::Version
|
|
46
|
+
version: '13.0'
|
|
47
|
+
type: :development
|
|
48
|
+
prerelease: false
|
|
49
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
50
|
+
requirements:
|
|
51
|
+
- - "~>"
|
|
52
|
+
- !ruby/object:Gem::Version
|
|
53
|
+
version: '13.0'
|
|
54
|
+
- !ruby/object:Gem::Dependency
|
|
55
|
+
name: rubocop
|
|
56
|
+
requirement: !ruby/object:Gem::Requirement
|
|
57
|
+
requirements:
|
|
58
|
+
- - "~>"
|
|
59
|
+
- !ruby/object:Gem::Version
|
|
60
|
+
version: '1.21'
|
|
61
|
+
type: :development
|
|
62
|
+
prerelease: false
|
|
63
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
64
|
+
requirements:
|
|
65
|
+
- - "~>"
|
|
66
|
+
- !ruby/object:Gem::Version
|
|
67
|
+
version: '1.21'
|
|
68
|
+
- !ruby/object:Gem::Dependency
|
|
69
|
+
name: lefthook
|
|
70
|
+
requirement: !ruby/object:Gem::Requirement
|
|
71
|
+
requirements:
|
|
72
|
+
- - "~>"
|
|
73
|
+
- !ruby/object:Gem::Version
|
|
74
|
+
version: '2.1'
|
|
75
|
+
type: :development
|
|
76
|
+
prerelease: false
|
|
77
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
78
|
+
requirements:
|
|
79
|
+
- - "~>"
|
|
80
|
+
- !ruby/object:Gem::Version
|
|
81
|
+
version: '2.1'
|
|
82
|
+
description: |
|
|
83
|
+
Rubycam is a pure-Ruby V4L2 library for controlling webcams and capturing
|
|
84
|
+
MJPG/YUYV frames, plus a dry-cli command-line tool. It includes a vendor
|
|
85
|
+
extension driver for OBSBOT Tiny cameras: privacy sleep/wake, AI tracking
|
|
86
|
+
modes, gimbal presets, tracking speed, HDR and exposure control. The GTK4
|
|
87
|
+
desktop viewer ships separately as the rubycam-gtk gem.
|
|
88
|
+
email:
|
|
89
|
+
- nathankidd@hey.com
|
|
90
|
+
executables:
|
|
91
|
+
- rubycam
|
|
92
|
+
extensions: []
|
|
93
|
+
extra_rdoc_files: []
|
|
94
|
+
files:
|
|
95
|
+
- LICENSE
|
|
96
|
+
- README.md
|
|
97
|
+
- TINY4LINUX_FEATURES.md
|
|
98
|
+
- exe/rubycam
|
|
99
|
+
- lib/rubycam.rb
|
|
100
|
+
- lib/rubycam/cli.rb
|
|
101
|
+
- lib/rubycam/controls.rb
|
|
102
|
+
- lib/rubycam/device.rb
|
|
103
|
+
- lib/rubycam/ioctl.rb
|
|
104
|
+
- lib/rubycam/obsbot.rb
|
|
105
|
+
- lib/rubycam/version.rb
|
|
106
|
+
- rubycam.gemspec
|
|
107
|
+
homepage: https://github.com/ruby-gtk-project/rubycam
|
|
108
|
+
licenses:
|
|
109
|
+
- MIT
|
|
110
|
+
metadata:
|
|
111
|
+
source_code_uri: https://github.com/ruby-gtk-project/rubycam
|
|
112
|
+
changelog_uri: https://github.com/ruby-gtk-project/rubycam/releases
|
|
113
|
+
rubygems_mfa_required: 'true'
|
|
114
|
+
rdoc_options: []
|
|
115
|
+
require_paths:
|
|
116
|
+
- lib
|
|
117
|
+
required_ruby_version: !ruby/object:Gem::Requirement
|
|
118
|
+
requirements:
|
|
119
|
+
- - ">="
|
|
120
|
+
- !ruby/object:Gem::Version
|
|
121
|
+
version: 3.1.0
|
|
122
|
+
required_rubygems_version: !ruby/object:Gem::Requirement
|
|
123
|
+
requirements:
|
|
124
|
+
- - ">="
|
|
125
|
+
- !ruby/object:Gem::Version
|
|
126
|
+
version: '0'
|
|
127
|
+
requirements: []
|
|
128
|
+
rubygems_version: 3.7.2
|
|
129
|
+
specification_version: 4
|
|
130
|
+
summary: Pure-Ruby V4L2 webcam library and CLI, with OBSBOT Tiny support
|
|
131
|
+
test_files: []
|