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,441 @@
1
+ # frozen_string_literal: true
2
+
3
+ ENV["ESCDELAY"] = "0"
4
+ require "curses"
5
+ require "io/console"
6
+
7
+ require "esphome"
8
+
9
+ require_relative "colors"
10
+ require_relative "entity"
11
+
12
+ module ESPHome
13
+ module Cli
14
+ class Monitor
15
+ include Colors
16
+
17
+ HEADER_ROWS = 3
18
+
19
+ class LoggerWrapper < Logger
20
+ def initialize(parent, level: Logger::DEBUG)
21
+ super(IO::NULL, level:)
22
+
23
+ @parent = parent
24
+ end
25
+
26
+ def add(severity, message = nil, progname = nil, ...)
27
+ severity ||= UNKNOWN
28
+ return true if severity < level
29
+
30
+ @parent.add(severity, message, progname, ...)
31
+ end
32
+ alias_method :log, :add
33
+ end
34
+ private_constant :LoggerWrapper
35
+
36
+ class << self
37
+ def run(...)
38
+ new(...).run
39
+ end
40
+ end
41
+
42
+ attr_reader :name_width, :win
43
+ attr_accessor :logger
44
+
45
+ def initialize(device,
46
+ actions: false,
47
+ device_log_level: nil,
48
+ connection_log_level: nil,
49
+ log_level: nil,
50
+ dump_config: false)
51
+ @device = device
52
+ @win = nil
53
+ @entities_by_key = {}
54
+ @entities = []
55
+ @log_lines = []
56
+ @current_entity = -1
57
+ @sub_active = false
58
+ @name_width = 0
59
+ @device.device_logger = LoggerWrapper.new(self, level: device_log_level || Logger::DEBUG)
60
+ @device.connection_logger = LoggerWrapper.new(self, level: connection_log_level || Logger::WARN)
61
+ @logger = LoggerWrapper.new(self, level: log_level || Logger::INFO)
62
+ @actions = actions
63
+ @dump_config = dump_config
64
+ @winch_trapped = false
65
+ @logwin = nil
66
+ @screen_initialized = false
67
+ @resize_pending = false
68
+ @ui_events = Queue.new
69
+ @log_mutex = Mutex.new
70
+ end
71
+
72
+ Logger::Severity.constants.each do |level|
73
+ class_eval <<~RUBY, __FILE__, __LINE__ + 1
74
+ def #{level.downcase}(msg = nil, ...) # def warn(msg = nil, ...)
75
+ add(Logger::#{level}, msg, ...) # add(Logger::WARN, msg, ...)
76
+ end # end
77
+ RUBY
78
+ end
79
+
80
+ def run
81
+ @device.on_message do |entity_or_log_line|
82
+ if entity_or_log_line.respond_to?(:key) && (entity_wrapper = @entities_by_key[entity_or_log_line.key])
83
+ entity_wrapper.touch
84
+ queue_ui_event(:entity, entity_wrapper)
85
+ elsif entity_or_log_line.is_a?(Action)
86
+ logger.info(entity_or_log_line.inspect)
87
+ else
88
+ logger.warn("Unexpected message #{entity_or_log_line.inspect}")
89
+ end
90
+ end
91
+
92
+ @device.on_connect do
93
+ logger.info("Connected")
94
+ @name_width = @device.entities.values.map { |e| e.name.length }.max || 0
95
+ @entities_by_key = {}
96
+ @entities = []
97
+ @device.entities.values.sort_by(&:name).each_with_index do |entity, idx|
98
+ simple_name = entity.class.name.split("::").last
99
+ entity_class = if Entities.const_defined?(simple_name)
100
+ Entities.const_get(simple_name, false)
101
+ else
102
+ Entity
103
+ end
104
+ entity_wrapper = entity_class.new(self, entity, idx)
105
+ @entities_by_key[entity.key] = entity_wrapper
106
+ @entities << entity_wrapper
107
+ end
108
+
109
+ @current_entity = @entities.index { |e| e.respond_to?(:activate) } || -1
110
+ queue_ui_event(:render_all)
111
+
112
+ @device.stream_states
113
+ @device.stream_actions if @actions
114
+ @device.stream_log(dump_config: @dump_config) if @device.device_logger.level < Logger::FATAL
115
+ end
116
+
117
+ @device.on_disconnect do
118
+ logger.info("Gracefully disconnected")
119
+ reconnect
120
+ end
121
+
122
+ install_winch_handler
123
+ @device.connect
124
+ ensure_screen
125
+ process_ui_events
126
+
127
+ begin
128
+ Thread.new do
129
+ @device.loop
130
+ rescue DeviceConnectionError, NotConnectedError, IOError, SocketError, SystemCallError, Timeout::Error => e
131
+ logger.warn("Connection lost: #{e}")
132
+ @device.disconnect
133
+ reconnect
134
+ retry
135
+ rescue => e
136
+ error("UNHANDLED EXCEPTION: #{e}", render: false)
137
+ e.backtrace.each do |line|
138
+ error(" #{line}", render: false)
139
+ end
140
+ queue_ui_event(:log)
141
+ retry
142
+ end
143
+
144
+ loop do
145
+ process_ui_events
146
+
147
+ case @win.getch
148
+ when Curses::Key::UP
149
+ new_entity = @entities.reverse.find { |e| e.index < @current_entity && e.respond_to?(:activate) }&.index
150
+ next unless new_entity
151
+
152
+ @entities[@current_entity]&.print(@win) if @current_entity >= 0
153
+ @current_entity = new_entity
154
+ @entities[@current_entity].print(@win, active: true)
155
+ @win.refresh
156
+ when Curses::Key::DOWN
157
+ new_entity = @entities.find { |e| e.index > @current_entity && e.respond_to?(:activate) }&.index
158
+ next unless new_entity
159
+
160
+ @entities[@current_entity]&.print(@win) if @current_entity >= 0
161
+ @current_entity = new_entity
162
+ @entities[@current_entity].print(@win, active: true)
163
+ @win.refresh
164
+ when Curses::Key::LEFT
165
+ next unless @current_entity >= 0
166
+
167
+ @entities[@current_entity].move_left
168
+ @entities[@current_entity].print(@win, active: true)
169
+ @win.refresh
170
+ when Curses::Key::RIGHT
171
+ next unless @current_entity >= 0
172
+
173
+ @entities[@current_entity].move_right
174
+ @entities[@current_entity].print(@win, active: true)
175
+ @win.refresh
176
+ when "\n".ord
177
+ entity_wrapper = @entities[@current_entity]
178
+ activate_entity(entity_wrapper) if entity_wrapper.respond_to?(:activate)
179
+ when 27 # Esc
180
+ break
181
+ when nil
182
+ next
183
+ end
184
+ end
185
+ rescue Interrupt
186
+ # exiting
187
+ ensure
188
+ close_screen
189
+ end
190
+ @device.disconnect
191
+ end
192
+
193
+ def add(_level, message = nil, progname = nil, render: true)
194
+ if message.nil?
195
+ message = if block_given?
196
+ yield
197
+ else
198
+ progname
199
+ end
200
+ end
201
+
202
+ log_line = "[#{Time.now.strftime("%H:%M:%S.%L")}] #{message}"
203
+ @log_mutex.synchronize do
204
+ @log_lines << log_line
205
+ @log_lines.shift if @log_lines.size > [20, log_area_height].max
206
+ end
207
+
208
+ puts log_line unless @screen_initialized
209
+ queue_ui_event(:log) if render
210
+ end
211
+ alias_method :log, :add
212
+
213
+ def resize_pending?
214
+ @resize_pending
215
+ end
216
+
217
+ private
218
+
219
+ def parse_ansi_and_render(line)
220
+ @logwin.clrtoeol
221
+ current_color = nil
222
+ current_attr = Curses::A_NORMAL
223
+
224
+ line.split(ANSI_ESCAPE_REGEX).each_with_index do |part, i|
225
+ if i.odd?
226
+ codes = part.split(";").map(&:to_i)
227
+ current_color, current_attr = parse_sgr_codes(codes)
228
+ elsif current_color
229
+ pair_id = current_color + 1
230
+ @logwin.attron(Curses.color_pair(pair_id) | current_attr) do
231
+ @logwin.addstr(part)
232
+ end
233
+ else
234
+ @logwin.attron(current_attr) do
235
+ @logwin.addstr(part)
236
+ end
237
+ end
238
+ end
239
+ rescue Curses::Error
240
+ nil
241
+ end
242
+
243
+ def log_area_height
244
+ return 20 unless @win
245
+
246
+ [@win.maxy - @entities.size - 4, 0].max
247
+ end
248
+
249
+ def render_log
250
+ return unless @logwin
251
+
252
+ @logwin.clear
253
+ @logwin.setpos(0, 0)
254
+
255
+ @log_mutex.synchronize do
256
+ @log_lines.each_with_index do |log_line, idx|
257
+ unless idx.zero?
258
+ if @logwin.cury == @logwin.maxy - 1
259
+ @logwin.scroll
260
+ @logwin.setpos(@logwin.cury, 0)
261
+ else
262
+ @logwin.setpos(@logwin.cury + 1, 0)
263
+ end
264
+ end
265
+
266
+ parse_ansi_and_render(log_line)
267
+ end
268
+ end
269
+
270
+ @logwin.refresh
271
+ rescue Curses::Error
272
+ nil
273
+ end
274
+
275
+ def rebuild_logwin
276
+ @logwin&.close
277
+ @logwin = nil
278
+
279
+ height = log_area_height
280
+ return if height <= 0
281
+
282
+ top = @entities.size + 4
283
+ return if top >= @win.maxy
284
+
285
+ @logwin = @win.subwin(height, @win.maxx, top, 0)
286
+ @logwin.scrollok(true)
287
+ rescue Curses::Error
288
+ @logwin = nil
289
+ end
290
+
291
+ def render_all
292
+ ensure_screen
293
+ rebuild_logwin
294
+ @win.clear
295
+
296
+ safe_setpos(@win, 0, 0)
297
+ safe_addstr(@win, "ESPHome")
298
+ safe_setpos(@win, 1, 0)
299
+ left_header = @device.friendly_name.to_s
300
+ right_header = "#{@device.esphome_version} - #{@device.compilation_time}"
301
+ safe_addstr(@win, left_header)
302
+ if right_header.length < @win.maxx
303
+ safe_setpos(@win, 1, @win.maxx - right_header.length)
304
+ safe_addstr(@win, right_header)
305
+ end
306
+ safe_setpos(@win, 2, 0)
307
+ safe_addstr(@win, "=" * @win.maxx)
308
+
309
+ @entities.each do |entity|
310
+ entity.print(@win, active: @current_entity == entity.index)
311
+ end
312
+
313
+ render_log
314
+
315
+ @win.refresh
316
+ end
317
+
318
+ def ensure_screen
319
+ return if @screen_initialized && @win
320
+
321
+ @win = Curses.init_screen
322
+ Curses.noecho
323
+ Curses.start_color
324
+ Curses.use_default_colors
325
+ @win.keypad = true
326
+ @win.timeout = 100
327
+ Colors::ANSI_COLOR_MAP.each_value do |color|
328
+ Curses.init_pair(color + 1, color, -1)
329
+ end
330
+ @screen_initialized = true
331
+ end
332
+
333
+ def close_screen
334
+ return unless @screen_initialized
335
+
336
+ @logwin&.close
337
+ @logwin = nil
338
+ Curses.close_screen
339
+ @win = nil
340
+ @screen_initialized = false
341
+ end
342
+
343
+ def install_winch_handler
344
+ return if @winch_trapped
345
+
346
+ @winch_trapped = true
347
+ Signal.trap(:WINCH) do
348
+ @resize_pending = true
349
+ end
350
+ end
351
+
352
+ def process_ui_events
353
+ if @resize_pending
354
+ if @screen_initialized
355
+ rows, cols = IO.console.winsize
356
+ Curses.resizeterm(rows, cols)
357
+ @win&.resize(rows, cols)
358
+ @win&.clear
359
+ else
360
+ close_screen
361
+ end
362
+ @resize_pending = false
363
+ queue_ui_event(:render_all)
364
+ end
365
+
366
+ render_all_needed = false
367
+ log_dirty = false
368
+ entity_updates = []
369
+
370
+ loop do
371
+ type, payload = @ui_events.pop(true)
372
+ case type
373
+ when :entity
374
+ entity_updates << payload
375
+ when :log
376
+ log_dirty = true
377
+ when :render_all
378
+ render_all_needed = true
379
+ end
380
+ rescue ThreadError
381
+ break
382
+ end
383
+
384
+ return if @sub_active
385
+
386
+ if render_all_needed
387
+ render_all
388
+ return
389
+ end
390
+
391
+ return if entity_updates.empty? && !log_dirty
392
+
393
+ ensure_screen
394
+ entity_updates.uniq.each do |entity|
395
+ entity.print(@win, active: @current_entity == entity.index)
396
+ end
397
+ render_log if log_dirty
398
+ @win.refresh
399
+ end
400
+
401
+ def queue_ui_event(type, payload = nil)
402
+ @ui_events << [type, payload]
403
+ end
404
+
405
+ def safe_setpos(window, row, column)
406
+ return if row.negative? || row >= window.maxy || column.negative? || column >= window.maxx
407
+
408
+ window.setpos(row, column)
409
+ rescue Curses::Error
410
+ nil
411
+ end
412
+
413
+ def safe_addstr(window, string)
414
+ return if string.nil? || string.empty?
415
+
416
+ remaining = window.maxx - window.curx
417
+ return if remaining <= 0
418
+
419
+ window.addstr(string[0, remaining])
420
+ rescue Curses::Error
421
+ nil
422
+ end
423
+
424
+ def reconnect
425
+ @device.connect
426
+ rescue DeviceConnectionError, NotConnectedError, IOError, SocketError, SystemCallError, Timeout::Error => e
427
+ logger.warn("Failed to reconnect: #{e}")
428
+ sleep 1
429
+ retry
430
+ end
431
+
432
+ def activate_entity(entity_wrapper)
433
+ @sub_active = true
434
+ entity_wrapper.activate
435
+ queue_ui_event(:render_all)
436
+ ensure
437
+ @sub_active = false
438
+ end
439
+ end
440
+ end
441
+ end
@@ -0,0 +1,86 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "httpx"
4
+ require_relative "error"
5
+
6
+ module ESPHome
7
+ class Dashboard
8
+ DEFAULT_URI = "http://localhost:6052/"
9
+
10
+ class << self
11
+ def address_and_encryption_key(uri, device_name)
12
+ dashboard = uri.is_a?(Dashboard) ? uri : new(uri)
13
+ device = dashboard.devices.find { |d| d["name"] == device_name }
14
+ raise NoSuchDeviceError, "Device not found: #{device_name}" unless device
15
+
16
+ address = device["address"]
17
+ encryption_key = dashboard.encryption_key(device["configuration"])
18
+ raise MissingEncryptionKeyError, "No encryption key found for device #{device_name}" unless encryption_key
19
+
20
+ [address, encryption_key]
21
+ end
22
+ end
23
+
24
+ def initialize(uri)
25
+ @http = HTTPX.plugin(:persistent).with(origin: uri)
26
+ @websocket = nil
27
+ end
28
+
29
+ def devices
30
+ @http.get("/devices").tap(&:raise_for_status).json["configured"]
31
+ end
32
+
33
+ def config(configuration)
34
+ @http.get("/json-config", params: { configuration: })
35
+ .tap(&:raise_for_status)
36
+ .json(allow_nan: true)
37
+ end
38
+
39
+ def encryption_key(configuration)
40
+ config(configuration).dig("api", "encryption", "key")
41
+ end
42
+
43
+ def compile(configuration, &)
44
+ websocket_command("/compile", { type: "spawn", configuration: }, &)
45
+ end
46
+
47
+ def upload(configuration, port: :ota, &)
48
+ port = "OTA" if port == :ota
49
+ websocket_command("/upload", { type: "spawn", configuration:, port: }, &)
50
+ end
51
+
52
+ def update(configuration, port: :ota, &)
53
+ compile(configuration, &) && upload(configuration, port:, &)
54
+ end
55
+
56
+ private
57
+
58
+ def websocket_command(path, json_message) # rubocop:disable Naming/PredicateMethod
59
+ driver = websocket.get(path).tap(&:raise_for_status).driver
60
+
61
+ driver.on(:open) do
62
+ driver.text(json_message.to_json)
63
+ end
64
+
65
+ exit_code = nil
66
+ driver.on(:message) do |msg|
67
+ data = JSON.parse(msg.data)
68
+ case data["event"]
69
+ when "line"
70
+ yield data["data"].gsub("\\033", "\033") if block_given?
71
+ when "exit"
72
+ driver.close
73
+ exit_code = data["code"]
74
+ end
75
+ end
76
+
77
+ driver.start
78
+ driver.run
79
+ exit_code.zero?
80
+ end
81
+
82
+ def websocket
83
+ @websocket ||= @http.plugin(:websocket)
84
+ end
85
+ end
86
+ end