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 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
@@ -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
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Rubycam
4
+ VERSION = "0.1.1"
5
+ 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: []