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.
Files changed (55) hide show
  1. checksums.yaml +4 -4
  2. data/exe/esphome-monitor +16 -14
  3. data/exe/esphome-serial-proxy +132 -0
  4. data/lib/esphome/action.rb +55 -0
  5. data/lib/esphome/api/README.md +3 -0
  6. data/lib/esphome/api/api.proto +2756 -0
  7. data/lib/esphome/api/api_options.proto +120 -0
  8. data/lib/esphome/api/api_options_pb.rb +20 -0
  9. data/lib/esphome/api/api_pb.rb +229 -0
  10. data/lib/esphome/api.rb +182 -0
  11. data/lib/esphome/cli/colors.rb +44 -0
  12. data/lib/esphome/cli/completion.rb +48 -0
  13. data/lib/esphome/cli/entities/button.rb +18 -0
  14. data/lib/esphome/cli/entities/climate.rb +196 -0
  15. data/lib/esphome/cli/entities/cover.rb +116 -0
  16. data/lib/esphome/cli/entities/date.rb +27 -0
  17. data/lib/esphome/cli/entities/date_time.rb +29 -0
  18. data/lib/esphome/cli/entities/form.rb +111 -0
  19. data/lib/esphome/cli/entities/lock.rb +20 -0
  20. data/lib/esphome/cli/entities/menu.rb +83 -0
  21. data/lib/esphome/cli/entities/number.rb +37 -0
  22. data/lib/esphome/cli/entities/select.rb +16 -0
  23. data/lib/esphome/cli/entities/subfields.rb +63 -0
  24. data/lib/esphome/cli/entities/switch.rb +18 -0
  25. data/lib/esphome/cli/entities/text.rb +16 -0
  26. data/lib/esphome/cli/entities/time.rb +40 -0
  27. data/lib/esphome/cli/entity.rb +78 -0
  28. data/lib/esphome/cli/monitor.rb +441 -0
  29. data/lib/esphome/dashboard.rb +86 -0
  30. data/lib/esphome/device.rb +416 -0
  31. data/lib/esphome/entities/binary_sensor.rb +66 -0
  32. data/lib/esphome/entities/button.rb +13 -0
  33. data/lib/esphome/entities/climate.rb +259 -0
  34. data/lib/esphome/entities/cover.rb +135 -0
  35. data/lib/esphome/entities/date.rb +23 -0
  36. data/lib/esphome/entities/date_time.rb +21 -0
  37. data/lib/esphome/entities/fan.rb +89 -0
  38. data/lib/esphome/entities/light.rb +158 -0
  39. data/lib/esphome/entities/lock.rb +53 -0
  40. data/lib/esphome/entities/number.rb +56 -0
  41. data/lib/esphome/entities/select.rb +27 -0
  42. data/lib/esphome/entities/sensor.rb +49 -0
  43. data/lib/esphome/entities/switch.rb +24 -0
  44. data/lib/esphome/entities/text.rb +36 -0
  45. data/lib/esphome/entities/text_sensor.rb +10 -0
  46. data/lib/esphome/entities/time.rb +39 -0
  47. data/lib/esphome/entity.rb +146 -0
  48. data/lib/esphome/error.rb +28 -0
  49. data/lib/esphome/serial_proxy.rb +372 -0
  50. data/lib/esphome/version.rb +5 -0
  51. data/lib/esphome.rb +5 -0
  52. data/lib/httpx/plugins/websocket.rb +62 -0
  53. data/lib/websocket/driver/httpx.rb +74 -0
  54. metadata +55 -5
  55. 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
@@ -0,0 +1,13 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ESPHome
4
+ module Entities
5
+ class Button < Entity
6
+ include HasDeviceClass
7
+
8
+ def press
9
+ device.send(Api::ButtonCommandRequest.new(key:))
10
+ end
11
+ end
12
+ end
13
+ end