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,111 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "menu"
4
+
5
+ module ESPHome
6
+ module Cli
7
+ module Entities
8
+ module FormPrompt
9
+ private
10
+
11
+ def prompt_form(title, initial_value:, suffix:, field_width:)
12
+ field, form, win = build_form_dialog(title, suffix, field_width)
13
+ return unless win
14
+
15
+ field.set_buffer(0, initial_value) if initial_value
16
+ form.post
17
+ win.box(0, 0)
18
+ win.setpos(0, 2)
19
+ safe_addstr(win, " #{title} ")
20
+ if suffix
21
+ win.setpos(1, field_width + 3)
22
+ safe_addstr(win, suffix)
23
+ end
24
+ win.setpos(1, 2)
25
+ win.refresh
26
+
27
+ loop do
28
+ case (ch = win.getch)
29
+ when Curses::KEY_RIGHT
30
+ form.driver(Curses::REQ_NEXT_CHAR)
31
+ when Curses::KEY_LEFT
32
+ form.driver(Curses::REQ_PREV_CHAR)
33
+ when Curses::KEY_BACKSPACE
34
+ form.driver(Curses::REQ_DEL_PREV)
35
+ when Curses::KEY_DC
36
+ form.driver(Curses::REQ_DEL_CHAR)
37
+ when "\n".ord
38
+ form.driver(Curses::REQ_VALIDATION)
39
+ value = field.buffer(0).strip
40
+ return nil if value.empty?
41
+
42
+ return value
43
+ when 27, Curses::KEY_RESIZE
44
+ return nil
45
+ when nil
46
+ next
47
+ else
48
+ form.driver(ch)
49
+ end
50
+ rescue Curses::RequestDeniedError, Curses::UnknownCommandError
51
+ next
52
+ end
53
+ ensure
54
+ form&.unpost
55
+ win&.close
56
+ end
57
+
58
+ def build_form_dialog(title, suffix, field_width)
59
+ width = field_width + 4 + (suffix ? suffix.length + 1 : 0)
60
+ win = build_dialog_window(3, width, title)
61
+ return [nil, nil, nil] unless win
62
+
63
+ field = Curses::Field.new(1, field_width, 0, 0, 0, 0)
64
+ field.set_back(Curses::A_UNDERLINE)
65
+ field.opts_off(Curses::O_AUTOSKIP)
66
+ form = Curses::Form.new([field])
67
+ form.set_win(win)
68
+ form.set_sub(win.derwin(1, field_width, 1, 2))
69
+ [field, form, win]
70
+ end
71
+ end
72
+
73
+ class Form < Entity
74
+ include MenuPrompt
75
+ include FormPrompt
76
+
77
+ def initialize(...)
78
+ super
79
+
80
+ @max_length = length_range&.end || 10
81
+ end
82
+
83
+ def length_range
84
+ 0..10
85
+ end
86
+
87
+ def suffix = nil
88
+
89
+ def initial_value = nil
90
+
91
+ def activate
92
+ value = prompt_form(form_title,
93
+ initial_value:,
94
+ suffix:,
95
+ field_width:)
96
+ command(value) if value
97
+ end
98
+
99
+ private
100
+
101
+ def form_title
102
+ name
103
+ end
104
+
105
+ def field_width
106
+ @max_length
107
+ end
108
+ end
109
+ end
110
+ end
111
+ end
@@ -0,0 +1,20 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "menu"
4
+
5
+ module ESPHome
6
+ module Cli
7
+ module Entities
8
+ class Lock < Menu
9
+ def options
10
+ supports_open? ? %w[lock unlock open] : %w[lock unlock]
11
+ end
12
+
13
+ def command(command)
14
+ cli.info("#{command}ing #{object_id_}")
15
+ super
16
+ end
17
+ end
18
+ end
19
+ end
20
+ end
@@ -0,0 +1,83 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ESPHome
4
+ module Cli
5
+ module Entities
6
+ module MenuPrompt
7
+ private
8
+
9
+ def prompt_menu(title, options, current = nil)
10
+ content_width = options.map(&:length).max + 4
11
+ win = build_dialog_window(options.length + 2, content_width, title)
12
+ return unless win
13
+
14
+ index = options.index(current) || 0
15
+ loop do
16
+ render_select_options(win, title, options, index)
17
+
18
+ case win.getch
19
+ when Curses::Key::UP
20
+ index = (index - 1) % options.length
21
+ when Curses::Key::DOWN
22
+ index = (index + 1) % options.length
23
+ when "\n".ord
24
+ return options[index]
25
+ when 27, Curses::KEY_RESIZE
26
+ return nil
27
+ when nil
28
+ next
29
+ end
30
+ end
31
+ ensure
32
+ win&.close
33
+ end
34
+
35
+ def render_select_options(win, title, options, index)
36
+ win.clear
37
+ win.box(0, 0)
38
+ win.setpos(0, 2)
39
+ safe_addstr(win, " #{title} ")
40
+ options.each_with_index do |option, idx|
41
+ win.setpos(idx + 1, 2)
42
+ if idx == index
43
+ win.attron(Curses::A_REVERSE) { safe_addstr(win, option) }
44
+ else
45
+ safe_addstr(win, option)
46
+ end
47
+ end
48
+ win.refresh
49
+ end
50
+
51
+ def build_dialog_window(height, width, title)
52
+ root = cli.win
53
+ return unless root
54
+
55
+ width = [width, title.length + 6].max
56
+ return if width > root.maxx || height > root.maxy
57
+
58
+ top = (index + Monitor::HEADER_ROWS).clamp(0, root.maxy - height)
59
+ left = (cli.name_width + 3).clamp(0, root.maxx - width)
60
+ win = Curses::Window.new(height, width, top, left)
61
+ win.keypad = true
62
+ win.timeout = 100
63
+ win
64
+ end
65
+ end
66
+
67
+ class Menu < Entity
68
+ include MenuPrompt
69
+
70
+ def activate
71
+ choice = prompt_menu(menu_title, options)
72
+ command(choice) if choice
73
+ end
74
+
75
+ private
76
+
77
+ def menu_title
78
+ name
79
+ end
80
+ end
81
+ end
82
+ end
83
+ end
@@ -0,0 +1,37 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "form"
4
+
5
+ module ESPHome
6
+ module Cli
7
+ module Entities
8
+ class Number < Form
9
+ def length_range
10
+ max_length = range&.end ? range.end.to_s.length : 10
11
+ max_length += accuracy_decimals + 1 if accuracy_decimals.positive?
12
+ 1..max_length
13
+ end
14
+
15
+ def suffix
16
+ unit_of_measurement
17
+ end
18
+
19
+ def initial_value
20
+ format("%.#{accuracy_decimals}f", state) if state
21
+ end
22
+
23
+ def command(value)
24
+ begin
25
+ value = Float(value)
26
+ rescue ArgumentError
27
+ cli.error("Invalid number: #{value}")
28
+ return
29
+ end
30
+
31
+ cli.info("Setting #{object_id_} to #{formatted_state(value)}")
32
+ set(value)
33
+ end
34
+ end
35
+ end
36
+ end
37
+ end
@@ -0,0 +1,16 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "menu"
4
+
5
+ module ESPHome
6
+ module Cli
7
+ module Entities
8
+ class Select < Menu
9
+ def command(value)
10
+ cli.info("Setting #{object_id_} to #{value}")
11
+ super
12
+ end
13
+ end
14
+ end
15
+ end
16
+ end
@@ -0,0 +1,63 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "menu"
4
+
5
+ module ESPHome
6
+ module Cli
7
+ module Entities
8
+ class Subfields < Entity
9
+ include MenuPrompt
10
+
11
+ def initialize(...)
12
+ super
13
+
14
+ @selected_subfield = 0
15
+ end
16
+
17
+ def move_left
18
+ return if subfields.size <= 1
19
+
20
+ @selected_subfield = (@selected_subfield - 1) % subfields.size
21
+ end
22
+
23
+ def move_right
24
+ return if subfields.size <= 1
25
+
26
+ @selected_subfield = (@selected_subfield + 1) % subfields.size
27
+ end
28
+
29
+ def activate
30
+ __send__(:"activate_#{active_subfield}")
31
+ end
32
+
33
+ private
34
+
35
+ def print_state(win, active:)
36
+ formatted_segments.each_with_index do |segment, idx|
37
+ safe_addstr(win, " ") unless idx.zero?
38
+ if active && idx == highlighted_segment_index
39
+ prefix, value, suffix = highlighted_segment_parts(idx, segment)
40
+ safe_addstr(win, prefix)
41
+ win.attron(Curses::A_REVERSE) { safe_addstr(win, value) }
42
+ safe_addstr(win, suffix)
43
+ else
44
+ safe_addstr(win, segment)
45
+ end
46
+ end
47
+ end
48
+
49
+ def active_subfield
50
+ subfields.fetch(@selected_subfield, subfields.first)
51
+ end
52
+
53
+ def highlighted_segment_index
54
+ subfield_segment_indexes.fetch(active_subfield)
55
+ end
56
+
57
+ def highlighted_segment_parts(_idx, segment)
58
+ ["", segment, ""]
59
+ end
60
+ end
61
+ end
62
+ end
63
+ end
@@ -0,0 +1,18 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "menu"
4
+
5
+ module ESPHome
6
+ module Cli
7
+ module Entities
8
+ class Switch < Menu
9
+ def options = %w[on off]
10
+
11
+ def command(command)
12
+ cli.info("Turning #{object_id_} #{command}")
13
+ __send__(command.to_sym)
14
+ end
15
+ end
16
+ end
17
+ end
18
+ end
@@ -0,0 +1,16 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "form"
4
+
5
+ module ESPHome
6
+ module Cli
7
+ module Entities
8
+ class Text < Form
9
+ def command(value)
10
+ cli.info("Setting #{object_id_} to #{value}")
11
+ super
12
+ end
13
+ end
14
+ end
15
+ end
16
+ end
@@ -0,0 +1,40 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "form"
4
+
5
+ module ESPHome
6
+ module Cli
7
+ module Entities
8
+ class Time < Form
9
+ def length_range
10
+ 25..25
11
+ end
12
+
13
+ def command(value)
14
+ if defined?(ActiveSupport::Duration)
15
+ begin
16
+ value = ActiveSupport::Duration.parse(value)
17
+ rescue ArgumentError
18
+ # ignore; try to parse another way
19
+ end
20
+ end
21
+
22
+ begin
23
+ if value.is_a?(String)
24
+ value = value.split(":").map { |v| Integer(v) }
25
+ raise ArgumentError if value.length > 3
26
+
27
+ value.unshift(0) while value.length < 3
28
+ end
29
+ rescue ArgumentError
30
+ cli.error("Invalid time: #{value}")
31
+ return
32
+ end
33
+
34
+ cli.info("Setting #{object_id_} to #{value.iso8601}")
35
+ super
36
+ end
37
+ end
38
+ end
39
+ end
40
+ end
@@ -0,0 +1,78 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "delegate"
4
+
5
+ module ESPHome
6
+ module Cli
7
+ class Entity < SimpleDelegator
8
+ attr_reader :cli, :index
9
+
10
+ def initialize(cli, entity, index)
11
+ super(entity)
12
+
13
+ @cli = cli
14
+ @index = index
15
+ touch
16
+ end
17
+
18
+ def touch = @last_update = Time.now
19
+ def move_left = nil
20
+ def move_right = nil
21
+
22
+ def print(win, clear_line: true, active: false)
23
+ row = @index + Monitor::HEADER_ROWS
24
+ return if row >= win.maxy
25
+
26
+ win.setpos(row, 0)
27
+ win.clrtoeol if clear_line
28
+ safe_addstr(win, "#{name.ljust(cli.name_width)} : ")
29
+ print_state(win, active:)
30
+
31
+ return unless __getobj__.is_a?(ESPHome::Entity::HasState)
32
+
33
+ timestamp = "[#{@last_update.strftime("%H:%M:%S.%L")}]"
34
+ return if timestamp.length >= win.maxx
35
+
36
+ pos = [win.cury, win.curx]
37
+ win.setpos(row, win.maxx - timestamp.length)
38
+ safe_addstr(win, timestamp)
39
+ win.setpos(*pos) if pos[1] < win.maxx
40
+ rescue Curses::Error
41
+ nil
42
+ end
43
+
44
+ private
45
+
46
+ def print_state(win, active:)
47
+ state = formatted_state
48
+ if active
49
+ win.attron(Curses::A_REVERSE) { safe_addstr(win, state) }
50
+ else
51
+ safe_addstr(win, state)
52
+ end
53
+ end
54
+
55
+ def safe_addstr(win, string)
56
+ return if string.nil? || string.empty?
57
+
58
+ remaining = win.maxx - win.curx
59
+ return if remaining <= 0
60
+
61
+ win.addstr(string[0, remaining])
62
+ end
63
+ end
64
+ end
65
+ end
66
+
67
+ require_relative "entities/button"
68
+ require_relative "entities/climate"
69
+ require_relative "entities/cover"
70
+ require_relative "entities/date"
71
+ require_relative "entities/date_time"
72
+ require_relative "entities/lock"
73
+ require_relative "entities/number"
74
+ require_relative "entities/select"
75
+ require_relative "entities/switch"
76
+ require_relative "entities/subfields"
77
+ require_relative "entities/text"
78
+ require_relative "entities/time"