hello-sense 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.
@@ -0,0 +1,142 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'json'
4
+ require 'net/http'
5
+
6
+ module Sense
7
+ class Client
8
+ API_HOST = 'https://api.hello.is'.freeze
9
+
10
+ include Account
11
+ include Alarms
12
+ include Alerts
13
+ include App
14
+ include Devices
15
+ include Expansions
16
+ include Firmware
17
+ include Insights
18
+ include Notifications
19
+ include Questions
20
+ include Sensors
21
+ include Session
22
+ include Sharing
23
+ include SleepSounds
24
+ include Speech
25
+ include Stats
26
+ include Store
27
+ include Support
28
+ include Timeline
29
+ include Trends
30
+
31
+ attr_reader :access_token
32
+
33
+ # XXX
34
+ #
35
+ # @param options [Hash]
36
+ # @option options [String] :access_token
37
+ # @option options [String] :client_id
38
+ # @option options [String] :client_secret
39
+ # @option options [String] :password
40
+ # @option options [String] :username
41
+ def initialize(options = {})
42
+ @access_token = options[:access_token]
43
+ @client_id = options[:client_id]
44
+ @client_secret = options[:client_secret]
45
+ @password = options[:password]
46
+ @username = options[:username]
47
+
48
+ @access_token = authorize_with_password! if @access_token.nil?
49
+ puts "** Using #{@access_token} as Sense access token"
50
+ end
51
+
52
+ # XXX
53
+ #
54
+ # @param path [String] relative path to use for the request
55
+ def delete(path)
56
+ response = connection.delete(path, headers)
57
+ data_or_error(response)
58
+ end
59
+
60
+ # XXX
61
+ #
62
+ # @param path [String] relative path to use for the request
63
+ def get(path)
64
+ response = connection.get(path, headers)
65
+ data_or_error(response)
66
+ end
67
+
68
+ # XXX
69
+ #
70
+ # @param path [String] relative path to use for the request
71
+ # @param data [Hash] data to send
72
+ def patch(path, data)
73
+ # XXX may need +set_form_data+
74
+ response = connection.patch(path, data, headers)
75
+ data_or_error(response)
76
+ end
77
+
78
+ # XXX
79
+ #
80
+ # @param path [String] relative path to use for the request
81
+ # @param data [Hash] data to send
82
+ def post(path, data)
83
+ request = Net::HTTP::Post.new(path, headers)
84
+ request.set_form_data(data)
85
+ response = connection.request(request)
86
+ data_or_error(response)
87
+ end
88
+
89
+ # @param path [String] relative path to use for the request
90
+ # @param data [Hash] data to send
91
+ def put(path, data)
92
+ request = Net::HTTP::Put.new(path, headers)
93
+ request.set_form_data(data)
94
+ response = connection.request(request)
95
+ data_or_error(response)
96
+ end
97
+
98
+ private
99
+
100
+ # XXX
101
+ #
102
+ # @return [Net::HTTP]
103
+ def connection
104
+ @connection ||= begin
105
+ require 'openssl'
106
+ require 'uri'
107
+
108
+ uri = URI.parse(API_HOST)
109
+ http = Net::HTTP.new(uri.host, uri.port)
110
+ http.ssl_version = :TLSv1_2
111
+ http.use_ssl = true
112
+ http.verify_mode = OpenSSL::SSL::VERIFY_PEER
113
+
114
+ http
115
+ end
116
+ end
117
+
118
+ # XXX
119
+ #
120
+ # @param response [Net::HTTPResponse]
121
+ # @return [Array, Hash, nil]
122
+ # @raise [APIError]
123
+ def data_or_error(response)
124
+ if response.code.to_i < 300
125
+ return response.body ? JSON.parse(response.body) : nil
126
+ end
127
+
128
+ raise APIError.new(response)
129
+ end
130
+
131
+ # XXX
132
+ #
133
+ # @return [Hash]
134
+ def headers
135
+ {
136
+ Accept: 'application/json',
137
+ Authorization: "Bearer #{@access_token}",
138
+ :'User-Agent' => "hello-sense Ruby gem/#{VERSION}",
139
+ }.freeze
140
+ end
141
+ end
142
+ end
@@ -0,0 +1,4 @@
1
+ module Sense
2
+ class InvalidEmailError < StandardError; end
3
+ class InvalidPasswordError < StandardError; end
4
+ end
@@ -0,0 +1,74 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Sense
4
+ module Devices
5
+ # @return [Hash]
6
+ #
7
+ # @example
8
+ # {
9
+ # "senses" => [{
10
+ # "id" => "ABCDEF1234567890",
11
+ # "firmware_version" => "11a1",
12
+ # "state" => "NORMAL",
13
+ # "last_updated" => 1483257600000,
14
+ # "color" => "UNKNOWN",
15
+ # "wifi_info" => {
16
+ # "ssid" => "Wifi? Why not!",
17
+ # "rssi" => 0,
18
+ # "last_updated" => 1420099200000,
19
+ # "condition" => "GOOD"
20
+ # },
21
+ # "hw_version" => "SENSE"
22
+ # }],
23
+ # "pills" => [{
24
+ # "id" => "0987654321FEDCBA",
25
+ # "firmware_version" => "2",
26
+ # "battery_level" => 0,
27
+ # "last_updated" => 1483257600000,
28
+ # "state" => "NORMAL",
29
+ # "color" => "BLUE",
30
+ # "battery_type" => "REMOVABLE"
31
+ # }]
32
+ # }
33
+
34
+ def devices
35
+ get('/v2/devices')
36
+ end
37
+
38
+ # @return [Hash]
39
+ #
40
+ # @example
41
+ # {
42
+ # "sense_id" => "ABCDEF1234567890",
43
+ # "paired_accounts" => 1
44
+ # }
45
+
46
+ def devices_info
47
+ get('/v2/devices/info')
48
+ end
49
+
50
+ def remove_device(device_id)
51
+ delete("/v2/devices/sense/#{device_id}/all")
52
+ end
53
+
54
+ def remove_pill(pill_id)
55
+ delete("/v2/devices/pill/#{pill_id}")
56
+ end
57
+
58
+ def remove_sense(pill_id)
59
+ delete("/v2/devices/sense/#{pill_id}")
60
+ end
61
+
62
+ def voice(device_id)
63
+ get("/v2/devices/sense/#{device_id}/voice")
64
+ end
65
+
66
+ def update_voice(device_id, data)
67
+ patch("/v2/devices/sense/#{device_id}/voice", data)
68
+ end
69
+
70
+ def swap_device(data)
71
+ put('/v2/devices/swap', data)
72
+ end
73
+ end
74
+ end
@@ -0,0 +1,53 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Sense
4
+ module Expansions
5
+ # @return [Array<Hash>]
6
+ #
7
+ # @example
8
+ # [
9
+ # {
10
+ # "id" => 1,
11
+ # "service_name" => "NEST",
12
+ # "device_name" => "Nest Thermostat",
13
+ # "company_name" => "Nest",
14
+ # "description" => "Connecting Sense to your Nest Learning Thermostat allows you to control your thermostat with Voice, or automatically set a specific temperature ahead of your Sense alarm, so you wake up to your ideal temperature every morning.",
15
+ # "icon" => {
16
+ # "phone_1x" => "https://hello-data.s3.amazonaws.com/expansions/icon-nest@1x.png",
17
+ # "phone_2x" => "https://hello-data.s3.amazonaws.com/expansions/icon-nest@2x.png",
18
+ # "phone_3x" => "https://hello-data.s3.amazonaws.com/expansions/icon-nest@3x.png"
19
+ # },
20
+ # "auth_uri" => "https://api.hello.is/v2/expansions/1/auth",
21
+ # "token_uri" => nil,
22
+ # "refresh_uri" => nil,
23
+ # "category" => "TEMPERATURE",
24
+ # "created" => 1495381368634,
25
+ # "completion_uri" => "https://api.hello.is/v2/expansions/redirect",
26
+ # "state" => "NOT_CONNECTED",
27
+ # "value_range" => {
28
+ # "min" => 9,
29
+ # "max" => 32
30
+ # }
31
+ # }
32
+ # ]
33
+ def expansions
34
+ get('/v2/expansions')
35
+ end
36
+
37
+ def expansion(expansion_id)
38
+ get("/v2/expansions/#{expansion_id}")
39
+ end
40
+
41
+ def update_expansion(expansion_id, data)
42
+ patch("/v2/expansions/#{expansion_id}", data)
43
+ end
44
+
45
+ def expansion_configurations(expansion_id)
46
+ get("/v2/expansions/#{expansion_id}/configurations")
47
+ end
48
+
49
+ def update_expansion_configurations(expansion_id, data)
50
+ patch("/v2/expansions/#{expansion_id}/configurations", data)
51
+ end
52
+ end
53
+ end
@@ -0,0 +1,20 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Sense
4
+ module Firmware
5
+ def request_firmware_update
6
+ post('/v1/ota/request_ota')
7
+ end
8
+
9
+ # @return [Hash]
10
+ #
11
+ # @example
12
+ # {
13
+ # "status" => "NOT_REQUIRED"
14
+ # }
15
+
16
+ def firmware_update_status
17
+ get('/v1/ota/status')
18
+ end
19
+ end
20
+ end
@@ -0,0 +1,57 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Sense
4
+ module Insights
5
+ # @return [Array<Hash>]
6
+ #
7
+ # @example
8
+ # [
9
+ # {
10
+ # "account_id" => 100000,
11
+ # "title" => "Still as the Night",
12
+ # "message" => "During the last 13 nights, you moved 23% less than the average person using Sense. About **9% of your sleep** consists of agitated sleep.",
13
+ # "category" => "SLEEP_QUALITY",
14
+ # "timestamp" => 1483257600000,
15
+ # "info_preview" => nil,
16
+ # "image" => {
17
+ # "phone_1x" => "https://s3.amazonaws.com/hello-data/insights_images/sleep_quality.png",
18
+ # "phone_2x" => "https://s3.amazonaws.com/hello-data/insights_images/sleep_quality@2x.png",
19
+ # "phone_3x" => "https://s3.amazonaws.com/hello-data/insights_images/sleep_quality@3x.png"
20
+ # },
21
+ # "category_name" => "Sleep Quality",
22
+ # "insight_type" => "DEFAULT",
23
+ # "id" => "fded667b-9e91-43f5-91de-258ac1fee9c2"
24
+ # }
25
+ # ]
26
+
27
+ def insights
28
+ get('/v2/insights')
29
+ end
30
+
31
+ # Known +category+s:
32
+ # * +AIR_QUALITY+
33
+ # * +BED_LIGHT_DURATION+
34
+ # * +HUMIDITY+
35
+ # * +LIGHT+
36
+ # * +SLEEP_QUALITY+
37
+ # * +SLEEP_TIME+
38
+ # * +TEMPERATURE+
39
+ # * +WAKE_VARIANCE+
40
+ #
41
+ # @param category [String]
42
+ # @return [Array<Hash>]
43
+ #
44
+ # @example
45
+ # [{
46
+ # "id" => 5,
47
+ # "category" => "AIR_QUALITY",
48
+ # "title" => "Clean air, better sleep",
49
+ # "text" => "Clean air is an important part of a healthy environment. A high concentration of airborne particulates (microscopic fragments of matter that can penetrate deep into your lungs) can irritate your throat and airways, exacerbate asthma symptoms, and disrupt your sleep.\n\nParticulates can come from indoor sources of pollutants like smoke, cooking fumes, and even some household cleaners. You should always take care to minimize your exposure to these types of pollutants, and open a window to help with ventilation if necessary.\n\nParticulate pollution can also come from outdoor sources, both natural and artificial. You can check the AirNow website to see if there’s an air quality advisory for your area at any time. If so, you should follow EPA recommendations, and limit your time spent outdoors.",
50
+ # "image_url" => ""
51
+ # }]
52
+
53
+ def insight(category)
54
+ get("/v2/insights/info/#{category}")
55
+ end
56
+ end
57
+ end
@@ -0,0 +1,36 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Sense
4
+ module Notifications
5
+ # @return [Array<Hash>]
6
+ #
7
+ # @example
8
+ # [
9
+ # {
10
+ # "type" => "SLEEP_SCORE",
11
+ # "enabled" => true,
12
+ # "name" => Sleep Score"
13
+ # }, {
14
+ # "type" => "SYSTEM",
15
+ # "enabled" => true,
16
+ # "name" => System Alerts"
17
+ # }, {
18
+ # "type" => "SLEEP_REMINDER",
19
+ # "enabled" => true,
20
+ # "name" => Sleep Reminder"
21
+ # }
22
+ # ]
23
+
24
+ def notifications
25
+ get('/v1/notifications')
26
+ end
27
+
28
+ def create_notification(data)
29
+ put('/v1/notifications', data)
30
+ end
31
+
32
+ def update_notifications(data)
33
+ post('/v1/notifications/registration', data)
34
+ end
35
+ end
36
+ end
@@ -0,0 +1,44 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Sense
4
+ module Questions
5
+ # @return [Array<Hash>]
6
+ #
7
+ # @example
8
+ # [
9
+ # {
10
+ # "id" => 2,
11
+ # "account_question_id" => 100000,
12
+ # "text" => "How was your sleep last night?",
13
+ # "choices" => {
14
+ # "id" => 69,
15
+ # "text" => "Great",
16
+ # "question_id" => 2
17
+ # }, {
18
+ # "id" => 70,
19
+ # "text" => "Okay",
20
+ # "question_id" => 2
21
+ # }, {
22
+ # "id" => 71,
23
+ # "text" => "Poor",
24
+ # "question_id" => 2
25
+ # }],
26
+ # "ask_local_date" => 1483257600000,
27
+ # "type" => "CHOICE",
28
+ # "ask_time" => "MORNING"
29
+ # }
30
+ # ]
31
+
32
+ def questions
33
+ get('/v1/questions')
34
+ end
35
+
36
+ def skip_question(data)
37
+ put('/v1/questions/skip', data)
38
+ end
39
+
40
+ def update_questions(data)
41
+ post('/v1/questions/save', data)
42
+ end
43
+ end
44
+ end
@@ -0,0 +1,125 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Sense
4
+ module Sensors
5
+ # Historical sensor data, sampled every five minutes.
6
+ #
7
+ # @param hours [Fixnum] how many hours of data to fetch
8
+ # @return [Hash]
9
+ #
10
+ # @note Seems to top out at around 920 hours -- higher numbers result in
11
+ # empty data; probably some timeout on the server cancels the lookup and
12
+ # falls back to empty data.
13
+ # @note If +from_utc+ is more than about 11 hours in the past the server
14
+ # will give a 400 Bad Request response. You can request far more than
15
+ # 11 +hours+, so +from_utc+ is always set to the current UTC timestamp.
16
+ #
17
+ # @example
18
+ # {
19
+ # "sound" => [
20
+ # {
21
+ # "datetime" => 1500001200000,
22
+ # "value" => 31.155998,
23
+ # "offset_millis" => -25200000
24
+ # },
25
+ # ...
26
+ # ],
27
+ # "humidity" => [
28
+ # {
29
+ # "datetime" => 1500001200000,
30
+ # "value" => 42.710575,
31
+ # "offset_millis" => -25200000
32
+ # },
33
+ # ...
34
+ # ],
35
+ # "light" => [
36
+ # {
37
+ # "datetime" => 1500001200000,
38
+ # "value" => 302.38342,
39
+ # "offset_millis" => -25200000
40
+ # },
41
+ # ...
42
+ # ],
43
+ # "temperature" => [
44
+ # {
45
+ # "datetime" => 1500001200000,
46
+ # "value" => 16.91,
47
+ # "offset_millis" => -25200000
48
+ # },
49
+ # ...
50
+ # ],
51
+ # "particulates" => [
52
+ # {
53
+ # "datetime" => 1500001200000,
54
+ # "value" => 7.8401413,
55
+ # "offset_millis" => -25200000
56
+ # },
57
+ # ...
58
+ # ]
59
+ # }
60
+
61
+ def sensors_historical(hours:)
62
+ require 'active_support/all'
63
+ timestamp = Time.now.utc.to_i * 1000
64
+
65
+ get("/v1/room/all_sensors/hours?quantity=#{hours}&from_utc=#{timestamp}")
66
+ end
67
+
68
+ # Known +type+s:
69
+ # * +TEMPERATURE+
70
+ # * +HUMIDITY+
71
+ # * +LIGHT+
72
+ # * +PARTICULATES+
73
+ # * +SOUND+
74
+ #
75
+ # @return [Array<Hash>]
76
+ #
77
+ # @example
78
+ # [
79
+ # {
80
+ # "name" => "Temperature",
81
+ # "type" => "TEMPERATURE",
82
+ # "unit" => "CELSIUS",
83
+ # "message" => "It's a bit warm.",
84
+ # "scale" => [{
85
+ # "name" => "Cold",
86
+ # "min" => nil,
87
+ # "max" => 9.99,
88
+ # "condition" => "ALERT"
89
+ # }, {
90
+ # "name" => "Cool",
91
+ # "min" => 10.0,
92
+ # "max" => 14.99,
93
+ # "condition" => "WARNING"
94
+ # }, {
95
+ # "name" => "Ideal",
96
+ # "min" => 15.0,
97
+ # "max" => 19.99,
98
+ # "condition" => "IDEAL"
99
+ # }, {
100
+ # "name" => "Warm",
101
+ # "min" => 20.0,
102
+ # "max" => 25.99,
103
+ # "condition" => "WARNING"
104
+ # }, {
105
+ # "name" => "Hot",
106
+ # "min" => 26.0,
107
+ # "max" => nil,
108
+ # "condition" => "ALERT"
109
+ # }],
110
+ # "condition" => "WARNING",
111
+ # "value" => 20.32
112
+ # }
113
+ # ...
114
+ # ]
115
+
116
+ def sensors
117
+ data = get('/v2/sensors')
118
+ data['sensors']
119
+ end
120
+
121
+ def update_sensors(data)
122
+ post('/v2/sensors', data)
123
+ end
124
+ end
125
+ end