ipcam 0.1.0
Sign up to get free protection for your applications and to get access to all the features.
- checksums.yaml +7 -0
- data/.gitignore +56 -0
- data/COPYRIGHT.md +99 -0
- data/Gemfile +2 -0
- data/LICENSE +21 -0
- data/README.md +59 -0
- data/Rakefile +2 -0
- data/bin/ipcam +129 -0
- data/ipcam.gemspec +50 -0
- data/lib/ipcam/main.rb +344 -0
- data/lib/ipcam/version.rb +3 -0
- data/lib/ipcam/webserver.rb +178 -0
- data/lib/ipcam/websock.rb +380 -0
- data/resource/common/js/msgpack-rpc.js +191 -0
- data/resource/common/js/util.js +135 -0
- data/resource/extern/css/bootstrap.min.css +7 -0
- data/resource/extern/css/ion.rangeSlider.min.css +1 -0
- data/resource/extern/css/pretty-checkbox.min.css +12 -0
- data/resource/extern/js/bootstrap.min.js +7 -0
- data/resource/extern/js/ion.rangeSlider.min.js +2 -0
- data/resource/extern/js/jquery-3.4.1.min.js +2 -0
- data/resource/extern/js/jquery.nicescroll.min.js +2 -0
- data/resource/extern/js/msgpack.min.js +2 -0
- data/resource/extern/js/popper.min.js +5 -0
- data/resource/ipcam/js/main.js +486 -0
- data/resource/ipcam/js/session.js +61 -0
- data/resource/ipcam/scss/main/style.scss +91 -0
- data/resource/ipcam/views/main.erb +68 -0
- data/resource/ipcam/views/settings.erb +19 -0
- data/run.sh +3 -0
- metadata +228 -0
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,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
|