ruby_home 0.2.1 → 0.2.6
Sign up to get free protection for your applications and to get access to all the features.
- checksums.yaml +4 -4
- data/.github/workflows/tests.yml +41 -0
- data/.gitignore +2 -2
- data/.rubocop.yml +2 -9
- data/.standard.yml +3 -0
- data/CHANGELOG.md +18 -0
- data/Gemfile +2 -2
- data/README.md +40 -77
- data/Rakefile +4 -4
- data/examples/air_purifier.rb +66 -0
- data/examples/air_quality_sensor.rb +119 -0
- data/examples/battery_service.rb +58 -0
- data/examples/carbon_dioxide_sensor.rb +78 -0
- data/examples/carbon_monoxide_sensor.rb +78 -0
- data/examples/contact_sensor.rb +66 -0
- data/examples/door.rb +48 -0
- data/examples/fan.rb +30 -0
- data/examples/fan_v2.rb +68 -0
- data/examples/garage_door_opener.rb +71 -0
- data/examples/heater_cooler.rb +92 -0
- data/examples/humidifier_dehumidifier.rb +88 -0
- data/examples/humidity_sensor.rb +62 -0
- data/examples/leak_sensor.rb +66 -0
- data/examples/light_sensor.rb +64 -0
- data/examples/lightbulb.rb +31 -0
- data/examples/lock_mechanism.rb +34 -0
- data/examples/motion_sensor.rb +66 -0
- data/examples/occupancy_sensor.rb +66 -0
- data/examples/outlet.rb +29 -0
- data/examples/security_system.rb +53 -0
- data/examples/smoke_sensor.rb +66 -0
- data/examples/switch.rb +16 -0
- data/examples/television.rb +73 -0
- data/examples/temperature_sensor.rb +62 -0
- data/examples/thermostat.rb +113 -0
- data/examples/window.rb +48 -0
- data/examples/window_covering.rb +72 -0
- data/lib/ruby_home.rb +21 -22
- data/lib/ruby_home/accessory.rb +1 -1
- data/lib/ruby_home/accessory_collection.rb +6 -6
- data/lib/ruby_home/accessory_info.rb +18 -18
- data/lib/ruby_home/characteristic.rb +13 -7
- data/lib/ruby_home/characteristic_collection.rb +13 -13
- data/lib/ruby_home/config/categories.yml +39 -0
- data/lib/ruby_home/config/characteristics.yml +190 -319
- data/lib/ruby_home/config/manual_characteristics.yml +278 -0
- data/lib/ruby_home/config/manual_services.yml +45 -0
- data/lib/ruby_home/config/services.yml +338 -304
- data/lib/ruby_home/configuration.rb +21 -2
- data/lib/ruby_home/device_id.rb +3 -3
- data/lib/ruby_home/dns/service.rb +4 -6
- data/lib/ruby_home/dns/text_record.rb +10 -10
- data/lib/ruby_home/errors.rb +1 -0
- data/lib/ruby_home/factories/characteristic_factory.rb +38 -37
- data/lib/ruby_home/factories/service_factory.rb +72 -72
- data/lib/ruby_home/factories/templates/characteristic_template.rb +6 -6
- data/lib/ruby_home/factories/templates/service_template.rb +12 -11
- data/lib/ruby_home/hap/crypto/chacha20poly1305.rb +2 -2
- data/lib/ruby_home/hap/crypto/hkdf.rb +4 -4
- data/lib/ruby_home/hap/crypto/session_key.rb +7 -7
- data/lib/ruby_home/hap/decrypter.rb +8 -8
- data/lib/ruby_home/hap/encrypter.rb +5 -6
- data/lib/ruby_home/hap/ev_response.rb +17 -17
- data/lib/ruby_home/hap/hap_request.rb +1 -1
- data/lib/ruby_home/hap/server.rb +4 -4
- data/lib/ruby_home/hap/server_handler.rb +8 -10
- data/lib/ruby_home/hap/session.rb +22 -22
- data/lib/ruby_home/hap/values/base_value.rb +3 -3
- data/lib/ruby_home/hap/values/bool_value.rb +4 -4
- data/lib/ruby_home/hap/values/float_value.rb +4 -4
- data/lib/ruby_home/hap/values/identify_value.rb +1 -1
- data/lib/ruby_home/hap/values/int32_value.rb +4 -4
- data/lib/ruby_home/hap/values/null_value.rb +1 -1
- data/lib/ruby_home/hap/values/string_value.rb +11 -11
- data/lib/ruby_home/hap/values/uint32_value.rb +5 -5
- data/lib/ruby_home/hap/values/uint8_value.rb +14 -14
- data/lib/ruby_home/hex_helper.rb +3 -3
- data/lib/ruby_home/http/application.rb +7 -7
- data/lib/ruby_home/http/controllers/accessories_controller.rb +3 -3
- data/lib/ruby_home/http/controllers/application_controller.rb +6 -6
- data/lib/ruby_home/http/controllers/characteristics_controller.rb +29 -29
- data/lib/ruby_home/http/controllers/identify_controller.rb +10 -10
- data/lib/ruby_home/http/controllers/pair_setups_controller.rb +19 -19
- data/lib/ruby_home/http/controllers/pair_verifies_controller.rb +9 -9
- data/lib/ruby_home/http/controllers/pairings_controller.rb +6 -6
- data/lib/ruby_home/http/serializers/accessory_serializer.rb +5 -5
- data/lib/ruby_home/http/serializers/characteristic_serializer.rb +27 -19
- data/lib/ruby_home/http/serializers/characteristic_value_serializer.rb +5 -5
- data/lib/ruby_home/http/serializers/object_serializer.rb +1 -1
- data/lib/ruby_home/http/serializers/service_serializer.rb +19 -9
- data/lib/ruby_home/http/serializers/uuid_helper.rb +2 -2
- data/lib/ruby_home/http/services/session_notifier.rb +8 -8
- data/lib/ruby_home/http/services/start_srp_service.rb +3 -3
- data/lib/ruby_home/http/services/verify_finish_service.rb +22 -22
- data/lib/ruby_home/http/services/verify_srp_service.rb +22 -22
- data/lib/ruby_home/identifier_cache.rb +4 -5
- data/lib/ruby_home/password.rb +14 -14
- data/lib/ruby_home/persistable.rb +19 -12
- data/lib/ruby_home/service.rb +5 -2
- data/lib/ruby_home/service_collection.rb +1 -1
- data/lib/ruby_home/version.rb +1 -1
- data/rubyhome.gemspec +31 -31
- data/sbin/characteristic_generator.rb +22 -24
- data/sbin/service_generator.rb +38 -19
- metadata +85 -44
- data/.travis.yml +0 -31
@@ -1,10 +1,11 @@
|
|
1
1
|
module RubyHome
|
2
2
|
class ServiceTemplate
|
3
|
-
|
4
|
-
|
3
|
+
FILENAMES = %w[services.yml manual_services.yml].freeze
|
4
|
+
FILEPATHS = FILENAMES.map { |filename| File.join(__dir__, "..", "..", "config", filename) }.freeze
|
5
|
+
DATA = FILEPATHS.flat_map { |filepath| YAML.load_file(filepath) }.freeze
|
5
6
|
|
6
7
|
def self.all
|
7
|
-
@all ||= DATA.map { |data| new(data) }
|
8
|
+
@all ||= DATA.map { |data| new(**data) }
|
8
9
|
end
|
9
10
|
|
10
11
|
def self.find_by(options)
|
@@ -15,25 +16,25 @@ module RubyHome
|
|
15
16
|
end
|
16
17
|
end
|
17
18
|
|
18
|
-
def initialize(name:, description:, uuid:,
|
19
|
+
def initialize(name:, description:, uuid:, optional_characteristic_names:, required_characteristic_names:)
|
19
20
|
@name = name
|
20
21
|
@description = description
|
21
22
|
@uuid = uuid
|
22
|
-
@
|
23
|
-
@
|
23
|
+
@optional_characteristic_names = optional_characteristic_names
|
24
|
+
@required_characteristic_names = required_characteristic_names
|
24
25
|
end
|
25
26
|
|
26
|
-
attr_reader :name, :description, :uuid, :
|
27
|
+
attr_reader :name, :description, :uuid, :optional_characteristic_names, :required_characteristic_names
|
27
28
|
|
28
29
|
def optional_characteristics
|
29
|
-
@optional_characteristics ||=
|
30
|
-
CharacteristicTemplate.find_by(
|
30
|
+
@optional_characteristics ||= optional_characteristic_names.map do |name|
|
31
|
+
CharacteristicTemplate.find_by(name: name)
|
31
32
|
end
|
32
33
|
end
|
33
34
|
|
34
35
|
def required_characteristics
|
35
|
-
@required_characteristics ||=
|
36
|
-
CharacteristicTemplate.find_by(
|
36
|
+
@required_characteristics ||= required_characteristic_names.map do |name|
|
37
|
+
CharacteristicTemplate.find_by(name: name)
|
37
38
|
end
|
38
39
|
end
|
39
40
|
end
|
@@ -6,11 +6,11 @@ module RubyHome
|
|
6
6
|
@key = key
|
7
7
|
end
|
8
8
|
|
9
|
-
def encrypt(nonce, message, additional_data=nil)
|
9
|
+
def encrypt(nonce, message, additional_data = nil)
|
10
10
|
chacha20poly1305ietf.encrypt(nonce, message, additional_data)
|
11
11
|
end
|
12
12
|
|
13
|
-
def decrypt(nonce, ciphertext, additional_data=nil)
|
13
|
+
def decrypt(nonce, ciphertext, additional_data = nil)
|
14
14
|
chacha20poly1305ietf.decrypt(nonce, ciphertext, additional_data)
|
15
15
|
end
|
16
16
|
|
@@ -4,19 +4,19 @@ module RubyHome
|
|
4
4
|
module HAP
|
5
5
|
module Crypto
|
6
6
|
class HKDF
|
7
|
-
def initialize(salt:, info:
|
7
|
+
def initialize(salt:, info:)
|
8
8
|
@salt = salt
|
9
9
|
@info = info
|
10
10
|
end
|
11
11
|
|
12
12
|
def encrypt(source)
|
13
13
|
byte_string = convert_string_to_byte_string(source)
|
14
|
-
GEM_HKDF.new(byte_string, hkdf_opts).
|
14
|
+
GEM_HKDF.new(byte_string, hkdf_opts).read(BYTE_LENGTH)
|
15
15
|
end
|
16
16
|
|
17
17
|
private
|
18
18
|
|
19
|
-
ALGORITHM =
|
19
|
+
ALGORITHM = "SHA512"
|
20
20
|
BYTE_LENGTH = 32
|
21
21
|
|
22
22
|
attr_reader :info, :salt, :source
|
@@ -33,7 +33,7 @@ module RubyHome
|
|
33
33
|
if string.encoding == Encoding::ASCII_8BIT
|
34
34
|
string
|
35
35
|
else
|
36
|
-
[string].pack(
|
36
|
+
[string].pack("H*")
|
37
37
|
end
|
38
38
|
end
|
39
39
|
end
|
@@ -16,15 +16,15 @@ module RubyHome
|
|
16
16
|
|
17
17
|
private
|
18
18
|
|
19
|
-
|
20
|
-
|
21
|
-
|
19
|
+
SALT = -"Control-Salt"
|
20
|
+
READ = -"Control-Read-Encryption-Key"
|
21
|
+
WRITE = -"Control-Write-Encryption-Key"
|
22
22
|
|
23
|
-
|
23
|
+
attr_reader :shared_secret
|
24
24
|
|
25
|
-
|
26
|
-
|
27
|
-
|
25
|
+
def generate_shared_secret_key(info)
|
26
|
+
Crypto::HKDF.new(info: info, salt: SALT).encrypt(shared_secret)
|
27
|
+
end
|
28
28
|
end
|
29
29
|
end
|
30
30
|
end
|
@@ -1,9 +1,9 @@
|
|
1
1
|
module RubyHome
|
2
2
|
module HAP
|
3
3
|
class Decrypter
|
4
|
-
AAD_LENGTH_BYTES = 2
|
5
|
-
AUTHENTICATE_TAG_LENGTH_BYTES = 16
|
6
|
-
NONCE_32_BIT_FIX_COMMENT_PART = [0].pack(
|
4
|
+
AAD_LENGTH_BYTES = 2
|
5
|
+
AUTHENTICATE_TAG_LENGTH_BYTES = 16
|
6
|
+
NONCE_32_BIT_FIX_COMMENT_PART = [0].pack("L").freeze
|
7
7
|
|
8
8
|
def initialize(key, count: 0)
|
9
9
|
@key = key
|
@@ -15,14 +15,14 @@ module RubyHome
|
|
15
15
|
read_pointer = 0
|
16
16
|
|
17
17
|
while read_pointer < data.length
|
18
|
-
little_endian_length_of_encrypted_data = data[read_pointer...read_pointer+AAD_LENGTH_BYTES]
|
19
|
-
length_of_encrypted_data = little_endian_length_of_encrypted_data.unpack1(
|
18
|
+
little_endian_length_of_encrypted_data = data[read_pointer...read_pointer + AAD_LENGTH_BYTES]
|
19
|
+
length_of_encrypted_data = little_endian_length_of_encrypted_data.unpack1("v")
|
20
20
|
read_pointer += AAD_LENGTH_BYTES
|
21
21
|
|
22
|
-
message = data[read_pointer...read_pointer+length_of_encrypted_data]
|
22
|
+
message = data[read_pointer...read_pointer + length_of_encrypted_data]
|
23
23
|
read_pointer += length_of_encrypted_data
|
24
24
|
|
25
|
-
auth_tag = data[read_pointer...read_pointer+AUTHENTICATE_TAG_LENGTH_BYTES]
|
25
|
+
auth_tag = data[read_pointer...read_pointer + AUTHENTICATE_TAG_LENGTH_BYTES]
|
26
26
|
read_pointer += AUTHENTICATE_TAG_LENGTH_BYTES
|
27
27
|
|
28
28
|
ciphertext = message + auth_tag
|
@@ -46,7 +46,7 @@ module RubyHome
|
|
46
46
|
end
|
47
47
|
|
48
48
|
def nonce
|
49
|
-
NONCE_32_BIT_FIX_COMMENT_PART + [count].pack(
|
49
|
+
NONCE_32_BIT_FIX_COMMENT_PART + [count].pack("Q")
|
50
50
|
end
|
51
51
|
|
52
52
|
def chacha20poly1305ietf
|
@@ -2,8 +2,8 @@ module RubyHome
|
|
2
2
|
module HAP
|
3
3
|
class Session
|
4
4
|
class Encrypter
|
5
|
-
MAX_FRAME_LENGTH = 1024
|
6
|
-
NONCE_32_BIT_FIX_COMMENT_PART = [0].pack(
|
5
|
+
MAX_FRAME_LENGTH = 1024
|
6
|
+
NONCE_32_BIT_FIX_COMMENT_PART = [0].pack("L").freeze
|
7
7
|
|
8
8
|
def initialize(key, count: 0)
|
9
9
|
@key = key
|
@@ -17,9 +17,9 @@ module RubyHome
|
|
17
17
|
while read_pointer < data.length
|
18
18
|
encrypted_frame = ""
|
19
19
|
|
20
|
-
frame = data[read_pointer...read_pointer+MAX_FRAME_LENGTH]
|
20
|
+
frame = data[read_pointer...read_pointer + MAX_FRAME_LENGTH]
|
21
21
|
length_of_encrypted_data = frame.length
|
22
|
-
little_endian_length_of_encrypted_data = [length_of_encrypted_data].pack(
|
22
|
+
little_endian_length_of_encrypted_data = [length_of_encrypted_data].pack("v")
|
23
23
|
|
24
24
|
encrypted_frame += little_endian_length_of_encrypted_data
|
25
25
|
encrypted_frame += chacha20poly1305ietf.encrypt(nonce, frame, little_endian_length_of_encrypted_data)
|
@@ -43,7 +43,7 @@ module RubyHome
|
|
43
43
|
end
|
44
44
|
|
45
45
|
def nonce
|
46
|
-
NONCE_32_BIT_FIX_COMMENT_PART + [count].pack(
|
46
|
+
NONCE_32_BIT_FIX_COMMENT_PART + [count].pack("Q")
|
47
47
|
end
|
48
48
|
|
49
49
|
def chacha20poly1305ietf
|
@@ -53,4 +53,3 @@ module RubyHome
|
|
53
53
|
end
|
54
54
|
end
|
55
55
|
end
|
56
|
-
|
@@ -13,28 +13,28 @@ module RubyHome
|
|
13
13
|
|
14
14
|
private
|
15
15
|
|
16
|
-
|
16
|
+
attr_reader :body, :session
|
17
17
|
|
18
|
-
|
19
|
-
|
20
|
-
|
18
|
+
CRLF = -"\x0d\x0a"
|
19
|
+
STATUS_LINE = -"EVENT/1.0 200 OK"
|
20
|
+
CONTENT_TYPE_LINE = -"Content-Type: application/hap+json"
|
21
21
|
|
22
|
-
|
23
|
-
|
24
|
-
|
22
|
+
def content_length_line
|
23
|
+
"Content-Length: #{body.length}"
|
24
|
+
end
|
25
25
|
|
26
|
-
|
27
|
-
|
28
|
-
|
29
|
-
|
30
|
-
|
26
|
+
def send_header(io)
|
27
|
+
data = STATUS_LINE + CRLF
|
28
|
+
data << CONTENT_TYPE_LINE + CRLF
|
29
|
+
data << content_length_line + CRLF
|
30
|
+
data << CRLF
|
31
31
|
|
32
|
-
|
33
|
-
|
32
|
+
io.write(data)
|
33
|
+
end
|
34
34
|
|
35
|
-
|
36
|
-
|
37
|
-
|
35
|
+
def send_body(io)
|
36
|
+
io.write(body)
|
37
|
+
end
|
38
38
|
end
|
39
39
|
end
|
40
40
|
end
|
data/lib/ruby_home/hap/server.rb
CHANGED
@@ -4,11 +4,11 @@ module RubyHome
|
|
4
4
|
def run(sock)
|
5
5
|
session = Session.new(sock)
|
6
6
|
|
7
|
-
|
7
|
+
loop do
|
8
8
|
req = HAPRequest.new(@config, session: session)
|
9
9
|
res = HAPResponse.new(@config)
|
10
10
|
begin
|
11
|
-
|
11
|
+
loop do
|
12
12
|
break if sock.to_io.wait_readable(0.5)
|
13
13
|
break if @status != :Running
|
14
14
|
end
|
@@ -30,13 +30,13 @@ module RubyHome
|
|
30
30
|
res.set_error(ex)
|
31
31
|
rescue ::WEBrick::HTTPStatus::Status => ex
|
32
32
|
res.status = ex.code
|
33
|
-
rescue
|
33
|
+
rescue => ex
|
34
34
|
@logger.error(ex)
|
35
35
|
res.set_error(ex, true)
|
36
36
|
ensure
|
37
37
|
if req.request_line
|
38
38
|
if req.keep_alive? && res.keep_alive?
|
39
|
-
req.fixup
|
39
|
+
req.fixup
|
40
40
|
end
|
41
41
|
res.send_response(session)
|
42
42
|
access_log(@config, req, res)
|
@@ -1,7 +1,7 @@
|
|
1
1
|
module RubyHome
|
2
2
|
module HAP
|
3
3
|
class RackHandler < ::Rack::Handler::WEBrick
|
4
|
-
def self.run(app, options={})
|
4
|
+
def self.run(app, options = {})
|
5
5
|
@server = Server.new(options)
|
6
6
|
@server.mount "/", RackHandler, app
|
7
7
|
yield @server if block_given?
|
@@ -10,7 +10,7 @@ module RubyHome
|
|
10
10
|
end
|
11
11
|
|
12
12
|
class ServerHandler
|
13
|
-
def initialize(configuration:
|
13
|
+
def initialize(configuration:)
|
14
14
|
@configuration = configuration
|
15
15
|
end
|
16
16
|
|
@@ -33,19 +33,19 @@ module RubyHome
|
|
33
33
|
{
|
34
34
|
Port: port,
|
35
35
|
Host: bind_address,
|
36
|
-
ServerSoftware:
|
36
|
+
ServerSoftware: "RubyHome",
|
37
37
|
Logger: server_logger,
|
38
38
|
AccessLog: []
|
39
39
|
}
|
40
40
|
end
|
41
41
|
|
42
42
|
def server_logger
|
43
|
-
if ENV[
|
44
|
-
WEBrick::Log
|
45
|
-
elsif ENV[
|
46
|
-
WEBrick::Log
|
43
|
+
if ENV["DEBUG"] == "debug"
|
44
|
+
WEBrick::Log.new($stdout, WEBrick::BasicLog::DEBUG)
|
45
|
+
elsif ENV["DEBUG"] == "info"
|
46
|
+
WEBrick::Log.new($stdout, WEBrick::BasicLog::INFO)
|
47
47
|
else
|
48
|
-
WEBrick::Log
|
48
|
+
WEBrick::Log.new("/dev/null", WEBrick::BasicLog::WARN)
|
49
49
|
end
|
50
50
|
end
|
51
51
|
|
@@ -61,5 +61,3 @@ module RubyHome
|
|
61
61
|
end
|
62
62
|
end
|
63
63
|
end
|
64
|
-
|
65
|
-
|
@@ -54,35 +54,35 @@ module RubyHome
|
|
54
54
|
|
55
55
|
private
|
56
56
|
|
57
|
-
|
57
|
+
attr_reader :socket, :encrypter_class, :decrypter_class
|
58
58
|
|
59
|
-
|
60
|
-
|
61
|
-
|
59
|
+
def received_encrypted_request?
|
60
|
+
@received_encrypted_request ||= false
|
61
|
+
end
|
62
62
|
|
63
|
-
|
64
|
-
|
65
|
-
|
63
|
+
def encrypter
|
64
|
+
@_encrypter ||= encrypter_class.new(accessory_to_controller_key)
|
65
|
+
end
|
66
66
|
|
67
|
-
|
68
|
-
|
69
|
-
|
67
|
+
def encryption_time?
|
68
|
+
accessory_to_controller_key? && received_encrypted_request?
|
69
|
+
end
|
70
70
|
|
71
|
-
|
72
|
-
|
73
|
-
|
71
|
+
def decrypter
|
72
|
+
@_decrypter ||= decrypter_class.new(controller_to_accessory_key)
|
73
|
+
end
|
74
74
|
|
75
|
-
|
76
|
-
|
77
|
-
|
75
|
+
def decryption_time?
|
76
|
+
!!controller_to_accessory_key
|
77
|
+
end
|
78
78
|
|
79
|
-
|
80
|
-
|
81
|
-
|
79
|
+
def accessory_to_controller_key?
|
80
|
+
!!accessory_to_controller_key
|
81
|
+
end
|
82
82
|
|
83
|
-
|
84
|
-
|
85
|
-
|
83
|
+
def controller_to_accessory_key?
|
84
|
+
!!controller_to_accessory_key
|
85
|
+
end
|
86
86
|
end
|
87
87
|
end
|
88
88
|
end
|
@@ -1,4 +1,4 @@
|
|
1
|
-
require
|
1
|
+
require "facets/string/modulize"
|
2
2
|
|
3
3
|
module RubyHome
|
4
4
|
class BaseValue
|
@@ -8,7 +8,7 @@ module RubyHome
|
|
8
8
|
Object.const_get("::RubyHome::#{template.format.modulize}Value") || NullValue
|
9
9
|
end
|
10
10
|
|
11
|
-
def initialize(characteristic_template=nil, initial_value=nil)
|
11
|
+
def initialize(characteristic_template = nil, initial_value = nil)
|
12
12
|
@template = characteristic_template
|
13
13
|
@value = initial_value
|
14
14
|
end
|
@@ -25,6 +25,6 @@ module RubyHome
|
|
25
25
|
|
26
26
|
private
|
27
27
|
|
28
|
-
|
28
|
+
attr_reader :template
|
29
29
|
end
|
30
30
|
end
|
@@ -1,12 +1,12 @@
|
|
1
|
-
require_relative
|
1
|
+
require_relative "base_value"
|
2
2
|
|
3
3
|
module RubyHome
|
4
4
|
class BoolValue < BaseValue
|
5
5
|
REMAPPED_VALUES = {
|
6
|
-
|
6
|
+
"0" => false,
|
7
7
|
0 => false,
|
8
|
-
|
9
|
-
1 => true
|
8
|
+
"1" => true,
|
9
|
+
1 => true
|
10
10
|
}.freeze
|
11
11
|
|
12
12
|
def default
|
@@ -1,4 +1,4 @@
|
|
1
|
-
require_relative
|
1
|
+
require_relative "base_value"
|
2
2
|
|
3
3
|
module RubyHome
|
4
4
|
class FloatValue < BaseValue
|
@@ -8,8 +8,8 @@ module RubyHome
|
|
8
8
|
|
9
9
|
private
|
10
10
|
|
11
|
-
|
12
|
-
|
13
|
-
|
11
|
+
def minimum_value
|
12
|
+
template.constraints.fetch("MinimumValue", 0)
|
13
|
+
end
|
14
14
|
end
|
15
15
|
end
|