-
-
Notifications
You must be signed in to change notification settings - Fork 1k
/
sureflap.15s.rb
executable file
·220 lines (179 loc) · 7.13 KB
/
sureflap.15s.rb
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
#!/usr/bin/env ruby
# <xbar.title>SureFlap Pet Status</xbar.title>
# <xbar.version>v1.4.0</xbar.version>
# <xbar.author>Henrik Nyh</xbar.author>
# <xbar.author.github>henrik</xbar.author.github>
# <xbar.desc>Show inside/outside status of pets using a SureFlap smart cat flap or pet door. Can also show notifications.</xbar.desc>
# <xbar.image>https://henrik-public.s3.eu-west-1.amazonaws.com/xbar_sureflap_screenshot.png</xbar.image>
# <xbar.dependencies>ruby</xbar.dependencies>
#
# <xbar.var>string(VAR_EMAIL=""): App login email.</xbar.var>
# <xbar.var>string(VAR_PASSWORD=""): App login password.</xbar.var>
# <xbar.var>boolean(VAR_NOTIFICATIONS=true): Show a notification when in/out state changes?</xbar.var>
# <xbar.var>string(VAR_PER_PET_SETTINGS=""): As JSON. Missing pet names and missing values will default. E.g.: {"My Outdoor Cat": {"in": "🏠🐈", "out": "🌳🐈"}, "My Indoor Cat": {"menu_bar": false}, "My Fake Cat": {"hidden": true}} in = Custom display in menu bar when in. out = Ditto when out. menu_bar = Set false to hide in menu bar but still show in expanded menu. hidden = Set true to hide in expanded menu, too.</xbar.var>
# <xbar.var>number(VAR_CACHE_VERSION=1): Increase to clear cache if the set of pets or doors changes.</xbar.var>
# By Henrik Nyh <https://henrik.nyh.se> 2019-12-16 under the MIT license.
# Heavily based on the https://github.com/alextoft/sureflap PHP code by Alex Toft.
#
# Has no dependencies outside the Ruby standard library (uses Net::HTTP directly and painfully).
require "net/http"
require "json"
require "pp"
require "time"
require "fileutils"
require "digest"
EMAIL = ENV["VAR_EMAIL"] == "" ? nil : ENV["VAR_EMAIL"]
PASSWORD = ENV["VAR_PASSWORD"] == "" ? nil : ENV["VAR_PASSWORD"]
NOTIFICATIONS = (ENV["VAR_NOTIFICATIONS"] == "true")
CACHE_VERSION = ENV["VAR_CACHE_VERSION"]
begin
PER_PET_SETTINGS = JSON.parse(ENV["VAR_PER_PET_SETTINGS"] || "{}")
rescue JSON::ParserError => e
puts "🙀 Bad settings"
puts "---"
puts "The per-pet settings JSON is invalid:"
puts e
exit
end
HIDE_PETS_IN_MENU_BAR = PER_PET_SETTINGS.select { |_k, v| v["menu_bar"] == false }.keys
IGNORE_PETS_ENTIRELY = PER_PET_SETTINGS.select { |_k, v| v["hidden"] == true }.keys
ENDPOINT = "https://app.api.surehub.io"
TOKEN_PATH = File.expand_path("~/.sureflap_token")
unless EMAIL && PASSWORD
puts "🙀 Auth missing"
puts "---"
puts "Please configure email and password in the plugin browser."
exit
end
AUTH_DATA = { email_address: EMAIL, password: PASSWORD, device_id: "0" }
# From https://github.com/barsoom/net_http_timeout_errors/blob/master/lib/net_http_timeout_errors.rb
NETWORK_ERRORS = [
EOFError,
Errno::ECONNREFUSED,
Errno::ECONNRESET,
Errno::EHOSTUNREACH,
Errno::EINVAL,
Errno::ENETUNREACH,
Errno::EPIPE,
Errno::ETIMEDOUT,
Net::HTTPBadResponse,
Net::HTTPHeaderSyntaxError,
Net::ProtocolError,
Net::ReadTimeout,
SocketError,
Timeout::Error, # Also covers subclasses like Net::OpenTimeout.
]
class StaleTokenError < StandardError; end
def handle_network_errors
yield
rescue *NETWORK_ERRORS => e
puts "🙀 Network error"
puts "---"
puts "Network error when trying to communicate with the SureFlap API!"
puts "Check that you're not offline."
puts "---"
puts "Technical details:"
puts "#{e.class.name}: #{e.message}"
exit
end
def handle_non_success(response)
return if response.code == "200"
puts "🙀 Bad response (#{response.code})"
puts "---"
puts response.message
puts response.body
exit
end
def post(path, data)
handle_network_errors do
uri = URI.join(ENDPOINT, path)
req = Net::HTTP::Post.new(uri, "Content-Type" => "application/json")
req.body = data.to_json
res = Net::HTTP.start(uri.hostname, uri.port, use_ssl: true) { |http| http.request(req) }
handle_non_success(res)
JSON.parse(res.body)
end
end
def get(path, token:, cache:)
if cache
cache_file = "/tmp/sureflap_#{Digest::SHA256.hexdigest("#{path}-#{token}")}_v#{CACHE_VERSION}"
return JSON.parse(File.read(cache_file)) if File.exist?(cache_file)
end
handle_network_errors do
uri = URI.join(ENDPOINT, path)
req = Net::HTTP::Get.new(uri,
"Content-Type" => "application/json",
"Authorization" => "Bearer #{token}",
)
res = Net::HTTP.start(uri.hostname, uri.port, use_ssl: true) { |http| http.request(req) }
raw_json = res.body
hash = JSON.parse(raw_json)
if res.code == "401" && hash.dig("error", "message") == [ "Token Signature could not be verified." ]
error_message = "HTTP error!\n#{res.code} #{res.message}\n#{hash.pretty_inspect}"
raise StaleTokenError, error_message
end
handle_non_success(res)
File.write(cache_file, raw_json) if cache
hash
end
end
def refresh_token
token = post("/api/auth/login", AUTH_DATA).dig("data", "token")
File.write(TOKEN_PATH, token)
token
end
# This method reuses an existing token until it becomes stale.
def with_fresh_token
retried = false
begin
token = File.exist?(TOKEN_PATH) && File.read(TOKEN_PATH) || refresh_token()
yield(token)
rescue StaleTokenError
raise if retried # Avoid endless loops.
retried = true
FileUtils.rm(TOKEN_PATH)
retry
end
end
with_fresh_token do |token|
# We assume a single household.
household_id = get("/api/household", token: token, cache: true).dig("data", 0, "id")
data =
get("/api/household/#{household_id}/pet", token: token, cache: true).fetch("data").map { |pet_data|
id = pet_data.fetch("id")
name = pet_data.fetch("name")
next if IGNORE_PETS_ENTIRELY.include?(name)
position_data = get("/api/pet/#{id}/position", token: token, cache: false).fetch("data")
is_inside = (position_data.fetch("where") == 1)
since = Time.parse(position_data.fetch("since")).localtime # Convert from UTC to local time.
[ name, [ id, is_inside, since ] ]
}.compact.to_h
pets_in_summary = data.keys - HIDE_PETS_IN_MENU_BAR - IGNORE_PETS_ENTIRELY
raise "There are no pets to summarize!" if pets_in_summary.empty?
icon = ->(is_inside) { is_inside ? "🏠" : "🌳" }
puts pets_in_summary.map { |name|
_id, is_inside, _since = data.fetch(name)
custom_display = PER_PET_SETTINGS.dig(name, is_inside ? "in" : "out")
custom_display || "#{icon.(is_inside)} #{display_name}"
}.join(" ")
puts "---"
today = Date.today
data.each do |name, (id, is_inside, since)|
if NOTIFICATIONS
inside_state_path = "/tmp/sureflap_#{id}_is_inside"
previous_is_inside_string = File.read(inside_state_path) rescue nil
if previous_is_inside_string && previous_is_inside_string != is_inside.to_s
system("osascript", "-e", %{display notification "#{icon.(is_inside)} #{name} #{is_inside ? "has entered… Hi #{name}!" : "has left… Bye #{name}!" }" with title "Cat flap"})
end
File.write(inside_state_path, is_inside)
end
formatting_string =
case since.to_date
when today then "%H:%M"
when today - 1 then "yesterday at %H:%M"
when (today - 2)..(today - 6) then "%a at %H:%M"
else "%b %-d %Y at %H:%M"
end
puts "#{icon.(is_inside)} #{name} is #{is_inside ? "inside" : "outside"} since #{since.strftime(formatting_string)}."
end
end