ipcam 0.1.0

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.
data/lib/ipcam/main.rb ADDED
@@ -0,0 +1,344 @@
1
+ #! /usr/bin/env ruby
2
+ # coding: utf-8
3
+
4
+ #
5
+ # Sample for v4l2-ruby
6
+ #
7
+ # Copyright (C) 2019 Hiroshi Kuwagata <kgt9221@gmail.com>
8
+ #
9
+
10
+ require 'v4l2'
11
+ require 'msgpack'
12
+
13
+ module IPCam
14
+ BASIS_SIZE = 640 * 480
15
+ Stop = Class.new(Exception)
16
+
17
+ class << self
18
+ def start
19
+ restore_db()
20
+
21
+ @mutex = Mutex.new
22
+ @camera = nil
23
+ @state = :READY
24
+ @img_que = Thread::Queue.new
25
+ @cam_thr = Thread.new {camera_thread}
26
+ @snd_thr = Thread.new {sender_thread}
27
+ @clients = []
28
+
29
+ WebServer.start(self)
30
+ WebSocket.start(self)
31
+
32
+ EM.run
33
+ end
34
+
35
+ def stop
36
+ @cam_thr.join
37
+
38
+ @snd_thr.raise(Stop)
39
+ @snd_thr.join
40
+
41
+
42
+ WebServer.stop
43
+ EM.stop
44
+ end
45
+
46
+ def restart_camera
47
+ @cam_thr.raise(Stop)
48
+ @cam_thr.join
49
+
50
+ @img_que.clear
51
+
52
+ @cam_thr = Thread.new {camera_thread}
53
+ end
54
+
55
+ def select_capabilities(cam)
56
+ ret = cam.frame_capabilities(:MJPEG).instance_eval {
57
+ self.sort! { |a,b|
58
+ da = (BASIS_SIZE - (a.width * a.height)).abs
59
+ db = (BASIS_SIZE - (b.width * b.height)).abs
60
+
61
+ da <=> db
62
+ }
63
+
64
+ self.first
65
+ }
66
+
67
+ return ret
68
+ end
69
+ private :select_capabilities
70
+
71
+ def pack_capability(cap)
72
+ ret = {
73
+ :width => cap.width,
74
+ :height => cap.height,
75
+ :rate => cap.rate.inject([]) { |m, n|
76
+ m << [n.numerator, n.denominator]
77
+ }
78
+ }
79
+
80
+ return ret
81
+ end
82
+ private :pack_capability
83
+
84
+ def create_capability_list(cam)
85
+ ret = cam.frame_capabilities(:MJPEG).inject([]) { |m, n|
86
+ m << pack_capability(n)
87
+ }
88
+
89
+ return ret
90
+ end
91
+ private :create_capability_list
92
+
93
+ def pack_integer_control(c)
94
+ ret = {
95
+ :type => :integer,
96
+ :id => c.id,
97
+ :name => c.name,
98
+ :value => c.default,
99
+ :min => c.min,
100
+ :max => c.max,
101
+ :step => c.step
102
+ }
103
+
104
+ return ret
105
+ end
106
+ private :pack_integer_control
107
+
108
+ def pack_boolean_control(c)
109
+ ret = {
110
+ :type => :boolean,
111
+ :id => c.id,
112
+ :name => c.name,
113
+ :value => c.default
114
+ }
115
+
116
+ return ret
117
+ end
118
+ private :pack_boolean_control
119
+
120
+ def pack_menu_control(c)
121
+ ret = {
122
+ :type => :menu,
123
+ :id => c.id,
124
+ :name => c.name,
125
+ :value => c.default,
126
+ :items => c.items.inject({}) {|m, n| m[n.name] = n.index; m}
127
+ }
128
+
129
+ return ret
130
+ end
131
+ private :pack_menu_control
132
+
133
+ def create_control_list(cam)
134
+ ret = cam.controls.inject([]) { |m, n|
135
+ case n
136
+ when Video4Linux2::Camera::IntegerControl
137
+ m << pack_integer_control(n)
138
+
139
+ when Video4Linux2::Camera::BooleanControl
140
+ m << pack_boolean_control(n)
141
+
142
+ when Video4Linux2::Camera::MenuControl
143
+ m << pack_menu_control(n)
144
+
145
+ else
146
+ raise("Unknwon control found #{n.class}")
147
+ end
148
+ }
149
+
150
+ return ret
151
+ end
152
+ private :create_control_list
153
+
154
+ def create_setting_entry(cam)
155
+ cap = select_capabilities(cam)
156
+ rate = cap.rate.sort.first
157
+
158
+ ret = {
159
+ :image_width => cap.width,
160
+ :image_height => cap.height,
161
+ :framerate => [rate.numerator, rate.denominator],
162
+ :capabilities => create_capability_list(cam),
163
+ :controls => create_control_list(cam)
164
+ }
165
+
166
+ return ret
167
+ end
168
+ private :create_setting_entry
169
+
170
+ def restore_db
171
+ begin
172
+ blob = $db_file.binread
173
+ @db = MessagePack.unpack(blob, :symbolize_keys => true)
174
+
175
+ rescue
176
+ begin
177
+ $db_file.delete
178
+ rescue Errno::ENOENT
179
+ # ignore
180
+ end
181
+
182
+ @db = {}
183
+ end
184
+ end
185
+ private :restore_db
186
+
187
+ def load_settings
188
+ ret = @db.dig(@camera.bus, @camera.name)
189
+
190
+ if not ret
191
+ ret = create_setting_entry(@camera)
192
+ (@db[@camera.bus] ||= {})[@camera.name] = ret
193
+
194
+ $db_file.binwrite(@db.to_msgpack)
195
+ end
196
+
197
+ @camera.image_height = ret[:image_height]
198
+ @camera.image_width = ret[:image_width]
199
+ @camera.framerate = Rational(*ret[:framerate])
200
+
201
+ ret[:controls].each { |ctr|
202
+ @camera.set_control(ctr[:id], ctr[:value]) rescue :ignore
203
+ }
204
+
205
+ return ret
206
+ end
207
+ private :load_settings
208
+
209
+ def broadcast(name, *args)
210
+ WebSocket.broadcast(name, *args)
211
+ end
212
+ private :broadcast
213
+
214
+ def camera_thread
215
+ $logger.info("main") {"camera thread start"}
216
+
217
+ @mutex.synchronize {
218
+ @camera = Video4Linux2::Camera.new($target)
219
+
220
+ if not @camera.support_formats.any? {|x| x.fcc == "MJPG"}
221
+ raise("#{$target} is not support Motion-JPEG")
222
+ end
223
+
224
+ @config = load_settings()
225
+
226
+ @camera.start
227
+ @state = :BUSY
228
+ }
229
+
230
+ loop {
231
+ @img_que << @camera.capture
232
+ }
233
+
234
+ rescue Stop
235
+ $logger.info("main") {"accept stop request"}
236
+ @state = :READY
237
+
238
+ rescue => e
239
+ $logger.error("main") {"camera error occured (#{e.message})"}
240
+ @state = :ABORTED
241
+
242
+ ensure
243
+ @camera&.stop if @camera&.busy?
244
+ @camera&.close
245
+ @camera = nil
246
+
247
+ $logger.info("main") {"camera thread stop"}
248
+ end
249
+ private :camera_thread
250
+
251
+ def sender_thread
252
+ $logger.info("main") {"sender thread start"}
253
+
254
+ loop {
255
+ data = @img_que.deq
256
+ @clients.each {|c| c[:que] << data}
257
+ broadcast(:update_image, {:type => "image/jpeg", :data => data})
258
+ }
259
+
260
+ rescue Stop
261
+ $logger.info("main") {"accept stop request"}
262
+ @clients.each {|c| c[:que] << nil}
263
+
264
+ ensure
265
+ $logger.info("main") {"sender thread stop"}
266
+ end
267
+
268
+ def get_camera_info
269
+ @mutex.synchronize {
270
+ ret = {
271
+ :device => $target,
272
+ :state => @state
273
+ }
274
+
275
+ ret.merge!(:bus => @camera.bus, :name => @camera.name) if @camera
276
+
277
+ return ret
278
+ }
279
+ end
280
+
281
+ def get_ident_string
282
+ raise("state violation") if @state != :BUSY
283
+ return "#{@camera.name}@#{@camera.bus}"
284
+ end
285
+
286
+ def get_config
287
+ raise("state violation") if @state != :BUSY
288
+ return @config
289
+ end
290
+
291
+ def set_image_size(width, height)
292
+ raise("state violation") if @state != :BUSY
293
+
294
+ @mutex.synchronize {
295
+ @config[:image_width] = width
296
+ @config[:image_height] = height
297
+ }
298
+
299
+ restart_camera()
300
+ broadcast(:update_image_size, width, height)
301
+ end
302
+
303
+ def set_framerate(num, deno)
304
+ raise("state violation") if @state != :BUSY
305
+
306
+ @mutex.synchronize {
307
+ @config[:framerate] = [num, deno]
308
+ }
309
+
310
+ restart_camera()
311
+ broadcast(:update_framerate, num, deno)
312
+ end
313
+
314
+ def set_control(id, val)
315
+ raise("state violation") if @state != :BUSY
316
+
317
+ entry = nil
318
+
319
+ @mutex.synchronize {
320
+ entry = @config[:controls].find {|obj| obj[:id] == id}
321
+ entry[:value] = val if entry
322
+ }
323
+
324
+ if entry
325
+ restart_camera()
326
+ broadcast(:update_control, id, val)
327
+ end
328
+ end
329
+
330
+ def save_config
331
+ @mutex.synchronize {
332
+ $db_file.binwrite(@db.to_msgpack)
333
+ }
334
+ end
335
+
336
+ def add_client(que)
337
+ @clients << {:que => que}
338
+ end
339
+
340
+ def remove_client(que)
341
+ @clients.reject! {|c| c[:que] == que}
342
+ end
343
+ end
344
+ end
@@ -0,0 +1,3 @@
1
+ module IPCam
2
+ VERSION = "0.1.0"
3
+ end
@@ -0,0 +1,178 @@
1
+ #! /usr/bin/env ruby
2
+ # coding: utf-8
3
+
4
+ #
5
+ # Sample for v4l2-ruby
6
+ #
7
+ # Copyright (C) 2019 Hiroshi Kuwagata <kgt9221@gmail.com>
8
+ #
9
+
10
+ require 'sinatra/base'
11
+ require 'sinatra/streaming'
12
+ require 'puma'
13
+ require 'puma/configuration'
14
+ require 'puma/events'
15
+ require 'eventmachine'
16
+ require 'securerandom'
17
+
18
+ module IPCam
19
+ class WebServer < Sinatra::Base
20
+ set :environment, (($develop_mode)? %s{development}: %s{production})
21
+ set :views, APP_RESOURCE_DIR + "views"
22
+ set :threaded, true
23
+ set :quiet, true
24
+
25
+ enable :logging
26
+
27
+ use Rack::CommonLogger, $logger
28
+
29
+ configure :development do
30
+ before do
31
+ cache_control :no_store, :no_cache, :must_revalidate,
32
+ :max_age => 0, :post_check => 0, :pre_check => 0
33
+ headers "Pragma" => "no-cache"
34
+ end
35
+ end
36
+
37
+ helpers do
38
+ def app
39
+ return (@app ||= settings.app)
40
+ end
41
+
42
+ def find_resource(type, name)
43
+ ret = RESOURCE_DIR + "extern" + type + name
44
+ return ret if ret.exist?
45
+
46
+ ret = RESOURCE_DIR + "common" + type + name
47
+ return ret if ret.exist?
48
+
49
+ ret = APP_RESOURCE_DIR + type + name
50
+ return ret if ret.exist?
51
+
52
+ return nil
53
+ end
54
+ end
55
+
56
+ get "/" do
57
+ redirect "/main"
58
+ end
59
+
60
+ get "/main" do
61
+ erb :main
62
+ end
63
+
64
+ get "/settings" do
65
+ erb :settings
66
+ end
67
+
68
+ get "/stream" do
69
+ boundary = SecureRandom.hex(20)
70
+ queue = Thread::Queue.new
71
+
72
+ content_type("multipart/x-mixed-replace; boundary=#{boundary}")
73
+
74
+ # pumaで:keep_openが動作しないのでちょっと面倒な方法で
75
+ # 対応
76
+ stream do |port|
77
+ port.callback {
78
+ app.remove_client(queue)
79
+ queue.clear
80
+ queue << nil
81
+ }
82
+
83
+ port << "\r\n"
84
+ app.add_client(queue)
85
+
86
+ loop {
87
+ data = queue.deq
88
+ break if not data
89
+
90
+ port << <<~EOT.b
91
+ --#{boundary}
92
+ Content-Type: image/jpeg\r
93
+ Content-Length: #{data.bytesize}\r
94
+ \r
95
+ EOT
96
+
97
+ port << data
98
+ }
99
+ end
100
+ end
101
+
102
+ get %r{/css/(.+).scss} do |name|
103
+ content_type('text/css')
104
+ scss name.to_sym, :views => APP_RESOURCE_DIR + "scss"
105
+ end
106
+
107
+ get %r{/(css|js|fonts)/(.+)} do |type, name|
108
+ path = find_resource(type, name)
109
+
110
+ if path
111
+ send_file(path)
112
+ else
113
+ halt 404
114
+ end
115
+ end
116
+
117
+ class << self
118
+ def bind_url
119
+ if $bind_addr.include?(":")
120
+ addr = "[#{$bind_addr}]" if $bind_addr.include?(":")
121
+ else
122
+ addr = $bind_addr
123
+ end
124
+
125
+ return "tcp://#{addr}:#{$http_port}"
126
+ end
127
+ private :bind_url
128
+
129
+ def env_string
130
+ return ($develop_mode)? 'development':'production'
131
+ end
132
+
133
+ def start(app)
134
+ set :app, app
135
+
136
+ config = Puma::Configuration.new { |user_config|
137
+ user_config.quiet
138
+ user_config.threads(4, 4)
139
+ user_config.bind(bind_url())
140
+ user_config.environment(env_string())
141
+ user_config.force_shutdown_after(-1)
142
+ user_config.app(WebServer)
143
+ }
144
+
145
+ @events = Puma::Events.new($log_device, $log_device)
146
+ @launch = Puma::Launcher.new(config, :events => @events)
147
+
148
+ # pumaのランチャークラスでのシグナルのハンドリングが
149
+ # 邪魔なのでオーバライドして無効化する
150
+ def @launch.setup_signals
151
+ # nothing
152
+ end
153
+
154
+ @thread = Thread.start {
155
+ begin
156
+ $logger.info('webserver') {"started #{bind_url()}"}
157
+ @launch.run
158
+ ensure
159
+ $logger.info('webserver') {"stopped"}
160
+ end
161
+ }
162
+
163
+ # サーバが立ち上がりきるまで待つ
164
+ booted = false
165
+ @events.on_booted {booted = true}
166
+ sleep 0.2 until booted
167
+ end
168
+
169
+ def stop
170
+ @launch.stop
171
+ @thread.join
172
+
173
+ remove_instance_variable(:@launch)
174
+ remove_instance_variable(:@thread)
175
+ end
176
+ end
177
+ end
178
+ end