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.
- 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
|