esphome 0.6.0 → 1.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 +4 -4
- data/exe/esphome-monitor +16 -14
- data/exe/esphome-serial-proxy +132 -0
- data/lib/esphome/action.rb +55 -0
- data/lib/esphome/api/README.md +3 -0
- data/lib/esphome/api/api.proto +2756 -0
- data/lib/esphome/api/api_options.proto +120 -0
- data/lib/esphome/api/api_options_pb.rb +20 -0
- data/lib/esphome/api/api_pb.rb +229 -0
- data/lib/esphome/api.rb +182 -0
- data/lib/esphome/cli/colors.rb +44 -0
- data/lib/esphome/cli/completion.rb +48 -0
- data/lib/esphome/cli/entities/button.rb +18 -0
- data/lib/esphome/cli/entities/climate.rb +196 -0
- data/lib/esphome/cli/entities/cover.rb +116 -0
- data/lib/esphome/cli/entities/date.rb +27 -0
- data/lib/esphome/cli/entities/date_time.rb +29 -0
- data/lib/esphome/cli/entities/form.rb +111 -0
- data/lib/esphome/cli/entities/lock.rb +20 -0
- data/lib/esphome/cli/entities/menu.rb +83 -0
- data/lib/esphome/cli/entities/number.rb +37 -0
- data/lib/esphome/cli/entities/select.rb +16 -0
- data/lib/esphome/cli/entities/subfields.rb +63 -0
- data/lib/esphome/cli/entities/switch.rb +18 -0
- data/lib/esphome/cli/entities/text.rb +16 -0
- data/lib/esphome/cli/entities/time.rb +40 -0
- data/lib/esphome/cli/entity.rb +78 -0
- data/lib/esphome/cli/monitor.rb +441 -0
- data/lib/esphome/dashboard.rb +86 -0
- data/lib/esphome/device.rb +416 -0
- data/lib/esphome/entities/binary_sensor.rb +66 -0
- data/lib/esphome/entities/button.rb +13 -0
- data/lib/esphome/entities/climate.rb +259 -0
- data/lib/esphome/entities/cover.rb +135 -0
- data/lib/esphome/entities/date.rb +23 -0
- data/lib/esphome/entities/date_time.rb +21 -0
- data/lib/esphome/entities/fan.rb +89 -0
- data/lib/esphome/entities/light.rb +158 -0
- data/lib/esphome/entities/lock.rb +53 -0
- data/lib/esphome/entities/number.rb +56 -0
- data/lib/esphome/entities/select.rb +27 -0
- data/lib/esphome/entities/sensor.rb +49 -0
- data/lib/esphome/entities/switch.rb +24 -0
- data/lib/esphome/entities/text.rb +36 -0
- data/lib/esphome/entities/text_sensor.rb +10 -0
- data/lib/esphome/entities/time.rb +39 -0
- data/lib/esphome/entity.rb +146 -0
- data/lib/esphome/error.rb +28 -0
- data/lib/esphome/serial_proxy.rb +372 -0
- data/lib/esphome/version.rb +5 -0
- data/lib/esphome.rb +5 -0
- data/lib/httpx/plugins/websocket.rb +62 -0
- data/lib/websocket/driver/httpx.rb +74 -0
- metadata +55 -5
- data/exe/esphome-update-all +0 -66
|
@@ -0,0 +1,416 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "ipaddr"
|
|
4
|
+
require "noise"
|
|
5
|
+
require "resolv"
|
|
6
|
+
require "socket"
|
|
7
|
+
require "timeout"
|
|
8
|
+
|
|
9
|
+
require_relative "api"
|
|
10
|
+
require_relative "error"
|
|
11
|
+
require_relative "serial_proxy"
|
|
12
|
+
|
|
13
|
+
module ESPHome
|
|
14
|
+
class Device
|
|
15
|
+
NOISE_PROTOCOL = "Noise_NNpsk0_25519_ChaChaPoly_SHA256"
|
|
16
|
+
NOISE_PROLOGUE = "NoiseAPIInit\0\0"
|
|
17
|
+
PROTOCOL_PLAINTEXT = 0
|
|
18
|
+
PROTOCOL_ENCRYPTED = 1
|
|
19
|
+
API_VERSION_MAJOR = 1
|
|
20
|
+
API_VERSION_MINOR = 14
|
|
21
|
+
|
|
22
|
+
LOG_LEVEL_MAP = Hash.new(Logger::UNKNOWN).merge(
|
|
23
|
+
LOG_LEVEL_NONE: Logger::FATAL,
|
|
24
|
+
LOG_LEVEL_ERROR: Logger::ERROR,
|
|
25
|
+
LOG_LEVEL_WARN: Logger::WARN,
|
|
26
|
+
LOG_LEVEL_INFO: Logger::INFO,
|
|
27
|
+
LOG_LEVEL_CONFIG: Logger::INFO,
|
|
28
|
+
LOG_LEVEL_DEBUG: Logger::DEBUG,
|
|
29
|
+
LOG_LEVEL_VERBOSE: Logger::DEBUG,
|
|
30
|
+
LOG_LEVEL_VERY_VERBOSE: Logger::DEBUG
|
|
31
|
+
).freeze
|
|
32
|
+
|
|
33
|
+
private_constant :NOISE_PROTOCOL,
|
|
34
|
+
:NOISE_PROLOGUE,
|
|
35
|
+
:PROTOCOL_PLAINTEXT,
|
|
36
|
+
:PROTOCOL_ENCRYPTED,
|
|
37
|
+
:API_VERSION_MAJOR,
|
|
38
|
+
:API_VERSION_MINOR,
|
|
39
|
+
:LOG_LEVEL_MAP
|
|
40
|
+
|
|
41
|
+
attr_reader :address,
|
|
42
|
+
:port,
|
|
43
|
+
:name,
|
|
44
|
+
:mac_address,
|
|
45
|
+
:esphome_version,
|
|
46
|
+
:compilation_time,
|
|
47
|
+
:model,
|
|
48
|
+
:project_name,
|
|
49
|
+
:project_version,
|
|
50
|
+
:manufacturer,
|
|
51
|
+
:friendly_name,
|
|
52
|
+
:suggested_area,
|
|
53
|
+
:serial_proxies
|
|
54
|
+
attr_accessor :connection_logger, :device_logger, :connect_timeout, :read_timeout
|
|
55
|
+
|
|
56
|
+
def initialize(address, encryption_key, port: 6053)
|
|
57
|
+
@address = address
|
|
58
|
+
@encryption_key = encryption_key.unpack1("m0")
|
|
59
|
+
@port = port
|
|
60
|
+
@socket = nil
|
|
61
|
+
@connection_logger = nil
|
|
62
|
+
@device_logger = nil
|
|
63
|
+
@noise = nil
|
|
64
|
+
@on_connect_callback = []
|
|
65
|
+
@on_disconnect_callback = []
|
|
66
|
+
@on_message_callback = []
|
|
67
|
+
@entities = nil
|
|
68
|
+
@connect_timeout = 5
|
|
69
|
+
@read_timeout = 5
|
|
70
|
+
@messages_to_replay = []
|
|
71
|
+
@serial_proxies = {}.freeze
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
def connect
|
|
75
|
+
return if @socket
|
|
76
|
+
|
|
77
|
+
addresses = resolve
|
|
78
|
+
exception = nil
|
|
79
|
+
addresses.each do |addrinfo|
|
|
80
|
+
@socket = addrinfo.connect
|
|
81
|
+
rescue => e
|
|
82
|
+
exception = e
|
|
83
|
+
end
|
|
84
|
+
if exception
|
|
85
|
+
raise DeviceConnectionError,
|
|
86
|
+
"Unable to connect to #{Array(address).join(", ")}:#{port}: #{exception.message}"
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
begin
|
|
90
|
+
# Noise logs warnings about not being able to load algorithms we don't even care about.
|
|
91
|
+
old_level = Noise.logger.level
|
|
92
|
+
Noise.logger.level = Logger::ERROR
|
|
93
|
+
noise = Noise::Connection::Initiator.new(NOISE_PROTOCOL)
|
|
94
|
+
ensure
|
|
95
|
+
Noise.logger.level = old_level
|
|
96
|
+
end
|
|
97
|
+
noise.psks = [@encryption_key]
|
|
98
|
+
noise.prologue = NOISE_PROLOGUE
|
|
99
|
+
noise.start_handshake
|
|
100
|
+
|
|
101
|
+
write_frame("")
|
|
102
|
+
write_frame("\0#{noise.write_message}")
|
|
103
|
+
|
|
104
|
+
_device_id = read_frame
|
|
105
|
+
noise.read_message(read_frame[1..])
|
|
106
|
+
@noise = noise
|
|
107
|
+
|
|
108
|
+
send(Api::HelloRequest.new(client_info: "esphome-ruby",
|
|
109
|
+
api_version_major: API_VERSION_MAJOR,
|
|
110
|
+
api_version_minor: API_VERSION_MINOR))
|
|
111
|
+
|
|
112
|
+
read_messages do |message|
|
|
113
|
+
if message.is_a?(Api::AuthenticationResponse)
|
|
114
|
+
raise InvalidPasswordError, "Invalid password" if message.invalid_password
|
|
115
|
+
|
|
116
|
+
next true
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
next false unless message.is_a?(Api::HelloResponse)
|
|
120
|
+
|
|
121
|
+
break
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
send(Api::DeviceInfoRequest.new)
|
|
125
|
+
|
|
126
|
+
read_messages do |message|
|
|
127
|
+
if message.is_a?(Api::AuthenticationResponse)
|
|
128
|
+
raise InvalidPasswordError, "Invalid password" if message.invalid_password
|
|
129
|
+
|
|
130
|
+
next true
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
next false unless message.is_a?(Api::DeviceInfoResponse)
|
|
134
|
+
|
|
135
|
+
@name = message.name
|
|
136
|
+
@mac_address = message.mac_address
|
|
137
|
+
@esphome_version = message.esphome_version
|
|
138
|
+
@compilation_time = message.compilation_time
|
|
139
|
+
@model = message.model
|
|
140
|
+
@project_name = message.project_name
|
|
141
|
+
@project_version = message.project_version
|
|
142
|
+
@manufacturer = message.manufacturer
|
|
143
|
+
@friendly_name = message.friendly_name
|
|
144
|
+
@suggested_area = message.suggested_area
|
|
145
|
+
old_proxies = @serial_proxies
|
|
146
|
+
@serial_proxies = {}
|
|
147
|
+
|
|
148
|
+
message.serial_proxies.each_with_index do |proxy, instance|
|
|
149
|
+
name = proxy.name.freeze
|
|
150
|
+
|
|
151
|
+
# keep the same object post-reconnect
|
|
152
|
+
proxy = old_proxies[name] if old_proxies[name]&.instance == instance
|
|
153
|
+
proxy = SerialProxy.new(self, name, instance, normalize_serial_proxy_port_type(proxy.port_type))
|
|
154
|
+
|
|
155
|
+
@serial_proxies[name] = proxy
|
|
156
|
+
@serial_proxies[proxy.instance] = proxy
|
|
157
|
+
end
|
|
158
|
+
@serial_proxies.freeze
|
|
159
|
+
|
|
160
|
+
missing_proxies = old_proxies.values.uniq - @serial_proxies.values
|
|
161
|
+
missing_proxies.each do |proxy|
|
|
162
|
+
proxy.instance_variable_set(:@closed, true)
|
|
163
|
+
proxy.instance_variable_set(:@device, nil)
|
|
164
|
+
end
|
|
165
|
+
|
|
166
|
+
@entities = nil
|
|
167
|
+
|
|
168
|
+
@on_connect_callback.each(&:call)
|
|
169
|
+
break
|
|
170
|
+
end
|
|
171
|
+
end
|
|
172
|
+
|
|
173
|
+
def disconnect
|
|
174
|
+
return unless connected?
|
|
175
|
+
|
|
176
|
+
send(Api::DisconnectRequest.new)
|
|
177
|
+
rescue DeviceConnectionError, NotConnectedError, IOError, SocketError, SystemCallError
|
|
178
|
+
nil
|
|
179
|
+
ensure
|
|
180
|
+
disconnected
|
|
181
|
+
end
|
|
182
|
+
|
|
183
|
+
def connected?
|
|
184
|
+
!!(@socket && @noise)
|
|
185
|
+
end
|
|
186
|
+
|
|
187
|
+
def entities
|
|
188
|
+
return @entities if @entities
|
|
189
|
+
|
|
190
|
+
send(Api::ListEntitiesRequest.new)
|
|
191
|
+
|
|
192
|
+
@entities = {}
|
|
193
|
+
read_messages do |message|
|
|
194
|
+
break if message.is_a?(Api::ListEntitiesDoneResponse)
|
|
195
|
+
|
|
196
|
+
next false unless message.class.respond_to?(:entity_class)
|
|
197
|
+
|
|
198
|
+
entity_class = message.class.entity_class
|
|
199
|
+
@entities[message.key] = entity_class.new(self, message)
|
|
200
|
+
true
|
|
201
|
+
end
|
|
202
|
+
@entities.freeze
|
|
203
|
+
end
|
|
204
|
+
|
|
205
|
+
def stream_states
|
|
206
|
+
send(Api::SubscribeStatesRequest.new)
|
|
207
|
+
end
|
|
208
|
+
|
|
209
|
+
def stream_log(level = :very_verbose, dump_config: false)
|
|
210
|
+
request = Api::SubscribeLogsRequest.new(level: :"LOG_LEVEL_#{level.upcase}")
|
|
211
|
+
request.dump_config = dump_config if dump_config
|
|
212
|
+
send(request)
|
|
213
|
+
end
|
|
214
|
+
|
|
215
|
+
def stream_actions
|
|
216
|
+
send(Api::SubscribeHomeassistantServicesRequest.new)
|
|
217
|
+
end
|
|
218
|
+
|
|
219
|
+
def loop
|
|
220
|
+
Kernel.loop do
|
|
221
|
+
message = nil
|
|
222
|
+
begin
|
|
223
|
+
message = if @messages_to_replay.empty?
|
|
224
|
+
read_message
|
|
225
|
+
else
|
|
226
|
+
@messages_to_replay.shift
|
|
227
|
+
end
|
|
228
|
+
rescue Timeout::Error
|
|
229
|
+
send(Api::PingRequest.new) if connected?
|
|
230
|
+
next
|
|
231
|
+
end
|
|
232
|
+
|
|
233
|
+
if message.is_a?(Api::PingRequest)
|
|
234
|
+
send(Api::PingResponse.new)
|
|
235
|
+
next
|
|
236
|
+
end
|
|
237
|
+
|
|
238
|
+
if message.respond_to?(:key) && (entity = @entities[message.key])
|
|
239
|
+
entity.update(message)
|
|
240
|
+
@on_message_callback.each { |callback| callback.call(entity) }
|
|
241
|
+
elsif message.is_a?(Api::SubscribeLogsResponse)
|
|
242
|
+
device_logger&.log(LOG_LEVEL_MAP[message.level], message.message)
|
|
243
|
+
elsif message.is_a?(Api::HomeassistantActionRequest)
|
|
244
|
+
action = Action.from_protobuf(message)
|
|
245
|
+
@on_message_callback.each { |callback| callback.call(action) }
|
|
246
|
+
elsif message.is_a?(Api::DisconnectRequest)
|
|
247
|
+
send(Api::DisconnectResponse.new)
|
|
248
|
+
disconnected
|
|
249
|
+
@on_disconnect_callback.each(&:call)
|
|
250
|
+
elsif message.is_a?(Api::DisconnectResponse)
|
|
251
|
+
disconnected
|
|
252
|
+
elsif message.is_a?(Api::PingResponse)
|
|
253
|
+
# Nothing to do
|
|
254
|
+
else
|
|
255
|
+
@on_message_callback.each { |callback| callback.call(message) }
|
|
256
|
+
end
|
|
257
|
+
end
|
|
258
|
+
rescue Interrupt
|
|
259
|
+
nil
|
|
260
|
+
end
|
|
261
|
+
|
|
262
|
+
%i[connect disconnect message].each do |callback|
|
|
263
|
+
class_eval <<-RUBY, __FILE__, __LINE__ + 1
|
|
264
|
+
def on_#{callback}(&block) # def on_connect(&block)
|
|
265
|
+
@on_#{callback}_callback << block # @on_connect_callback << block
|
|
266
|
+
block # block
|
|
267
|
+
end # end
|
|
268
|
+
#
|
|
269
|
+
def skip_#{callback}(&block) # def skip_connect_callback(&block)
|
|
270
|
+
@on_#{callback}_callback.delete(block) # @on_connect_callback.delete(block)
|
|
271
|
+
self # end
|
|
272
|
+
end
|
|
273
|
+
RUBY
|
|
274
|
+
end
|
|
275
|
+
|
|
276
|
+
def send(message)
|
|
277
|
+
raise NotConnectedError, "Encryption not yet set up" unless connected?
|
|
278
|
+
|
|
279
|
+
connection_logger&.debug { "> #{message.inspect}" }
|
|
280
|
+
serialized_message = message.to_proto
|
|
281
|
+
unencrypted_message = [message.class.descriptor.id,
|
|
282
|
+
serialized_message.length,
|
|
283
|
+
serialized_message].pack("nnA*")
|
|
284
|
+
encrypted_message = @noise.encrypt(unencrypted_message)
|
|
285
|
+
write_frame(encrypted_message)
|
|
286
|
+
end
|
|
287
|
+
|
|
288
|
+
private
|
|
289
|
+
|
|
290
|
+
def normalize_serial_proxy_port_type(port_type)
|
|
291
|
+
port_type.to_s.delete_prefix("SERIAL_PROXY_PORT_TYPE_").downcase.to_sym
|
|
292
|
+
end
|
|
293
|
+
|
|
294
|
+
def resolve
|
|
295
|
+
hosts = Array(address)
|
|
296
|
+
addresses = []
|
|
297
|
+
all_hosts = []
|
|
298
|
+
hosts.each do |host|
|
|
299
|
+
begin
|
|
300
|
+
IPAddr.new(host)
|
|
301
|
+
addresses.concat(Addrinfo.getaddrinfo(host, port, nil, Socket::SOCK_STREAM))
|
|
302
|
+
next
|
|
303
|
+
rescue IPAddr::InvalidAddressError
|
|
304
|
+
# Not an IP address, continue to treat it as a hostname
|
|
305
|
+
end
|
|
306
|
+
|
|
307
|
+
# if we've found any explicit IP addresses, don't bother looking at anything else
|
|
308
|
+
next unless addresses.empty?
|
|
309
|
+
|
|
310
|
+
# try bare hostname as well if the hostname includes ".local". Not strictly correct,
|
|
311
|
+
# but mDNS might be stale, and other methods work well
|
|
312
|
+
if host.end_with?(".local")
|
|
313
|
+
all_hosts << host[..-7]
|
|
314
|
+
elsif host.end_with?(".local.")
|
|
315
|
+
all_hosts << host[..-8]
|
|
316
|
+
end
|
|
317
|
+
all_hosts << host
|
|
318
|
+
end
|
|
319
|
+
|
|
320
|
+
# if we've found any explicit IP addresses, don't bother doing any lookups
|
|
321
|
+
return addresses unless addresses.empty?
|
|
322
|
+
|
|
323
|
+
return Addrinfo.getaddrinfo(all_hosts.first, port, nil, Socket::SOCK_STREAM) if all_hosts.size == 1
|
|
324
|
+
|
|
325
|
+
addresses = Queue.new
|
|
326
|
+
threads = all_hosts.map do |host|
|
|
327
|
+
Thread.new do
|
|
328
|
+
# Resolv doesn't support mDNS (well), so we have to fall back to getaddrinfo, which
|
|
329
|
+
# may not properly work in parallel because it takes the GIL
|
|
330
|
+
if host.end_with?(".local", ".local.")
|
|
331
|
+
addresses << Addrinfo.getaddrinfo(host, port, nil, Socket::SOCK_STREAM)
|
|
332
|
+
else
|
|
333
|
+
Resolv.each_address(host) do |addr|
|
|
334
|
+
addresses << Addrinfo.getaddrinfo(addr, port, nil, Socket::SOCK_STREAM)
|
|
335
|
+
end
|
|
336
|
+
end
|
|
337
|
+
end
|
|
338
|
+
end
|
|
339
|
+
|
|
340
|
+
resolved = addresses.pop
|
|
341
|
+
threads.each(&:kill)
|
|
342
|
+
threads.each(&:join)
|
|
343
|
+
|
|
344
|
+
resolved.concat(addresses.pop) until addresses.empty?
|
|
345
|
+
resolved.uniq
|
|
346
|
+
end
|
|
347
|
+
|
|
348
|
+
def disconnected
|
|
349
|
+
@socket&.close
|
|
350
|
+
@socket = nil
|
|
351
|
+
@noise = nil
|
|
352
|
+
@entities = nil
|
|
353
|
+
end
|
|
354
|
+
|
|
355
|
+
def write_frame(data)
|
|
356
|
+
raise NotConnectedError, "Not connected" unless @socket
|
|
357
|
+
|
|
358
|
+
@socket.write([PROTOCOL_ENCRYPTED, data.length, data].pack("cnA*"))
|
|
359
|
+
rescue => e
|
|
360
|
+
connection_logger&.warn("Error writing to socket: #{e}")
|
|
361
|
+
disconnected
|
|
362
|
+
raise
|
|
363
|
+
end
|
|
364
|
+
|
|
365
|
+
def read_frame
|
|
366
|
+
raise NotConnectedError, "Not connected" unless @socket
|
|
367
|
+
|
|
368
|
+
@socket.wait_readable(@read_timeout) or raise Timeout::Error
|
|
369
|
+
header = @socket.read(3)
|
|
370
|
+
raise ConnectionClosedError, "No data" if header.nil?
|
|
371
|
+
|
|
372
|
+
type, length = header.unpack("cn")
|
|
373
|
+
raise PlaintextProtocolError, "Plaintext protocol not supported" if type == PROTOCOL_PLAINTEXT
|
|
374
|
+
raise UnknownProtocolError, "Unrecognized protocol #{type}" unless type == PROTOCOL_ENCRYPTED
|
|
375
|
+
|
|
376
|
+
@socket.wait_readable(@read_timeout) or raise Timeout::Error
|
|
377
|
+
@socket.read(length)
|
|
378
|
+
rescue Timeout::Error
|
|
379
|
+
raise
|
|
380
|
+
rescue => e
|
|
381
|
+
connection_logger&.warn("Error reading from socket: #{e}")
|
|
382
|
+
disconnected
|
|
383
|
+
raise
|
|
384
|
+
end
|
|
385
|
+
|
|
386
|
+
def read_message
|
|
387
|
+
encrypted_message = read_frame
|
|
388
|
+
decrypted_message = @noise.decrypt(encrypted_message)
|
|
389
|
+
id, length, encoded_message = decrypted_message.unpack("nna*")
|
|
390
|
+
if length != encoded_message.length
|
|
391
|
+
raise InvalidMessageLengthError, "Unexpected message length #{encoded_message.length}; expected #{length}"
|
|
392
|
+
end
|
|
393
|
+
|
|
394
|
+
klass = Api::ID_TO_MESSAGE[id]
|
|
395
|
+
raise UnknownMessageError, "Unrecognized message id #{id}" unless klass
|
|
396
|
+
|
|
397
|
+
klass.decode(encoded_message).tap do |message|
|
|
398
|
+
connection_logger&.debug { "< #{message.inspect}" }
|
|
399
|
+
end
|
|
400
|
+
end
|
|
401
|
+
|
|
402
|
+
# return true from the yield to say you handled it
|
|
403
|
+
def read_messages
|
|
404
|
+
prior_messages_to_replay = @messages_to_replay
|
|
405
|
+
@messages_to_replay = []
|
|
406
|
+
Kernel.loop do
|
|
407
|
+
message = if prior_messages_to_replay.empty?
|
|
408
|
+
read_message
|
|
409
|
+
else
|
|
410
|
+
prior_messages_to_replay.shift
|
|
411
|
+
end
|
|
412
|
+
@messages_to_replay << message unless yield message
|
|
413
|
+
end
|
|
414
|
+
end
|
|
415
|
+
end
|
|
416
|
+
end
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module ESPHome
|
|
4
|
+
module Entities
|
|
5
|
+
class BinarySensor < Entity
|
|
6
|
+
DEVICE_CLASSES = Hash.new({ true => "on", false => "off" }.freeze).merge(
|
|
7
|
+
"battery" => { true => "low", false => "normal" }.freeze,
|
|
8
|
+
"battery_charging" => { true => "charging", false => "not charging" }.freeze,
|
|
9
|
+
"carbon_monoxide" => { true => "detected", false => "clear" }.freeze,
|
|
10
|
+
"cold" => { true => "cold", false => "normal" }.freeze,
|
|
11
|
+
"connectivity" => { true => "connected", false => "disconnected" }.freeze,
|
|
12
|
+
"door" => { true => "open", false => "closed" }.freeze,
|
|
13
|
+
"garage_door" => { true => "open", false => "closed" }.freeze,
|
|
14
|
+
"gas" => { true => "detected", false => "clear" }.freeze,
|
|
15
|
+
"heat" => { true => "hot", false => "normal" }.freeze,
|
|
16
|
+
"light" => { true => "detected", false => "not detected" }.freeze,
|
|
17
|
+
"lock" => { true => "locked", false => "unlocked" }.freeze,
|
|
18
|
+
"moisture" => { true => "wet", false => "dry" }.freeze,
|
|
19
|
+
"motion" => { true => "detected", false => "clear" }.freeze,
|
|
20
|
+
"moving" => { true => "moving", false => "stopped" }.freeze,
|
|
21
|
+
"occupancy" => { true => "occupied", false => "not occupied" }.freeze,
|
|
22
|
+
"opening" => { true => "open", false => "closed" }.freeze,
|
|
23
|
+
"plug" => { true => "plugged in", false => "unplugged" }.freeze,
|
|
24
|
+
"power" => { true => "detected", false => "no power" }.freeze,
|
|
25
|
+
"presence" => { true => "home", false => "away" }.freeze,
|
|
26
|
+
"problem" => { true => "problem", false => "ok" }.freeze,
|
|
27
|
+
"running" => { true => "running", false => "not running" }.freeze,
|
|
28
|
+
"safety" => { true => "unsafe", false => "safe" }.freeze,
|
|
29
|
+
"smoke" => { true => "detected", false => "clear" }.freeze,
|
|
30
|
+
"sound" => { true => "detected", false => "not detected" }.freeze,
|
|
31
|
+
"tamper" => { true => "tampered", false => "clear" }.freeze,
|
|
32
|
+
"update" => { true => "available", false => "up-to-date" }.freeze,
|
|
33
|
+
"vibration" => { true => "detected", false => "clear" }.freeze,
|
|
34
|
+
"window" => { true => "open", false => "closed" }.freeze
|
|
35
|
+
).freeze
|
|
36
|
+
|
|
37
|
+
include HasDeviceClass
|
|
38
|
+
include HasState
|
|
39
|
+
|
|
40
|
+
def initialize(_device, list_entities_response)
|
|
41
|
+
super
|
|
42
|
+
@status = list_entities_response.is_status_binary_sensor
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def status?
|
|
46
|
+
@status
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def formatted_state
|
|
50
|
+
return super if state.nil?
|
|
51
|
+
|
|
52
|
+
DEVICE_CLASSES[device_class][state]
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
private
|
|
56
|
+
|
|
57
|
+
def inspection_vars
|
|
58
|
+
super + [:status?]
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def hideable?(var, val)
|
|
62
|
+
super || (var == :status? && !val)
|
|
63
|
+
end
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
end
|