esphome 1.0.0 → 1.1.1

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 (54) hide show
  1. checksums.yaml +4 -4
  2. data/exe/esphome-monitor +4 -12
  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 +373 -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 +54 -2
@@ -0,0 +1,373 @@
1
+ # frozen_string_literal: true
2
+
3
+ # this file is often required in isolation
4
+ require "esphome"
5
+ require "timeout"
6
+ require "uri"
7
+
8
+ module ESPHome
9
+ class SerialProxy
10
+ class WaitReadable < RuntimeError
11
+ include ::IO::WaitReadable
12
+ end
13
+
14
+ attr_reader :device,
15
+ :name,
16
+ :instance,
17
+ :port_type,
18
+ :baud,
19
+ :data_bits,
20
+ :parity,
21
+ :stop_bits
22
+
23
+ class << self
24
+ def open(address, encryption_key = nil, instance = nil, **kwargs)
25
+ if address.is_a?(URI) && encryption_key.nil? && instance.nil?
26
+ encryption_key = address.password
27
+ instance = address.path[1..]
28
+ instance = nil if instance.empty?
29
+ address = address.host
30
+ end
31
+ raise ArgumentError, "missing encryption key" unless encryption_key
32
+
33
+ device = Device.new(address, encryption_key)
34
+ device.connect
35
+
36
+ instance ||= 0
37
+ io = device.serial_proxies[instance]
38
+ begin
39
+ io ||= device.serial_proxies[Integer(instance)]
40
+ rescue ArgumentError
41
+ # ignore; will raise below anyway
42
+ end
43
+
44
+ raise NoSuchSerialProxyError, "No serial proxy #{instance.inspect}" unless io
45
+
46
+ io.set_modem_params(**kwargs) unless kwargs.empty?
47
+ loop_thread = io.instance_variable_set(:@loop_thread, Thread.new do
48
+ Thread.current.report_on_exception = false
49
+ device.loop
50
+ rescue IOError
51
+ raise unless io.closed?
52
+ end)
53
+ io.open
54
+
55
+ if block_given?
56
+ begin
57
+ yield io
58
+ ensure
59
+ io.close
60
+ device.disconnect
61
+ loop_thread.join
62
+ end
63
+ nil
64
+ else
65
+ io
66
+ end
67
+ end
68
+ end
69
+
70
+ # @!visibility private
71
+ def initialize(device, name, instance, port_type)
72
+ @device = device
73
+ @name = name
74
+ @instance = instance
75
+ @port_type = port_type
76
+ @buffer = "".b
77
+ @mutex = Mutex.new
78
+ @data_ready = ConditionVariable.new
79
+ @request_mutex = Mutex.new
80
+ @closed = true
81
+ @baud = nil
82
+ @data_bits = nil
83
+ @parity = nil
84
+ @stop_bits = nil
85
+ @queue = Queue.new
86
+
87
+ @on_connect_callback = device.on_connect do
88
+ open unless closed?
89
+ end
90
+
91
+ @on_disconnect_callback = device.on_disconnect do
92
+ @queue << nil
93
+ @mutex.synchronize do
94
+ @data_ready.broadcast
95
+ end
96
+ end
97
+
98
+ @on_message_callback = device.on_message do |message|
99
+ case message
100
+ when Api::SerialProxyDataReceived
101
+ next unless message.instance == instance
102
+
103
+ @mutex.synchronize do
104
+ next if @closed
105
+
106
+ @buffer << message.data.b
107
+ @data_ready.broadcast
108
+ end
109
+ when Api::SerialProxyRequestResponse
110
+ next unless message.instance == instance
111
+
112
+ @queue << message
113
+ end
114
+ end
115
+ end
116
+
117
+ def set_modem_params(baud: nil, data_bits: nil, parity: nil, stop_bits: nil)
118
+ raise ArgumentError, "Parity must be :none, :even, or :odd" if !parity.nil? && !%i[none even odd].include?(parity)
119
+ raise MissingProxyError unless device
120
+
121
+ current_baud = nil
122
+ current_data_bits = nil
123
+ current_parity = nil
124
+ current_stop_bits = nil
125
+ @mutex.synchronize do
126
+ @baud = baud || @baud || 115_200
127
+ @data_bits = data_bits || @data_bits || 8
128
+ @parity = parity || @parity || :none
129
+ @stop_bits = stop_bits || @stop_bits || 1
130
+ current_baud = @baud
131
+ current_data_bits = @data_bits
132
+ current_parity = @parity
133
+ current_stop_bits = @stop_bits
134
+ end
135
+
136
+ return unless device.connected?
137
+
138
+ device.send(Api::SerialProxyConfigureRequest.new(instance:,
139
+ baudrate: current_baud,
140
+ flow_control: false,
141
+ parity: normalize_serial_proxy_parity(current_parity),
142
+ stop_bits: current_stop_bits,
143
+ data_size: current_data_bits))
144
+ end
145
+
146
+ def open
147
+ raise MissingProxyError unless device
148
+
149
+ @request_mutex.synchronize do
150
+ @mutex.synchronize do
151
+ return self unless @closed
152
+
153
+ @closed = false
154
+ @data_ready.broadcast
155
+ end
156
+
157
+ return self unless device.connected?
158
+
159
+ device.send(Api::SerialProxyRequest.new(instance:,
160
+ type: :SERIAL_PROXY_REQUEST_TYPE_SUBSCRIBE))
161
+ end
162
+
163
+ self
164
+ rescue
165
+ @mutex.synchronize do
166
+ @closed = true
167
+ @data_ready.broadcast
168
+ end
169
+ raise
170
+ end
171
+
172
+ def close
173
+ loop_thread = @mutex.synchronize do
174
+ already_closed = @closed
175
+ @closed = true
176
+ @data_ready.broadcast
177
+ [already_closed, @loop_thread]
178
+ end
179
+
180
+ return if loop_thread.first
181
+
182
+ # We're in a SerialProxy.open block; just close the connection without unsubscribing
183
+ if loop_thread.last
184
+ device&.disconnect
185
+ loop_thread.last.join
186
+ @mutex.synchronize do
187
+ @loop_thread = nil
188
+ end
189
+ return
190
+ end
191
+ return unless device&.connected?
192
+
193
+ @request_mutex.synchronize do
194
+ return unless device&.connected?
195
+
196
+ device.send(Api::SerialProxyRequest.new(instance:,
197
+ type: :SERIAL_PROXY_REQUEST_TYPE_UNSUBSCRIBE))
198
+ end
199
+ end
200
+
201
+ def write(data)
202
+ raise EOFError, "closed stream" if closed?
203
+ raise MissingDeviceError unless device
204
+ raise EOFError, "serial proxy disconnected" unless device.connected?
205
+
206
+ encoded = String(data).b
207
+ device.send(Api::SerialProxyWriteRequest.new(instance:, data: encoded))
208
+ encoded.bytesize
209
+ end
210
+
211
+ def flush
212
+ raise EOFError, "closed stream" if closed?
213
+ raise MissingDeviceError unless device
214
+ raise EOFError, "serial proxy disconnected" unless device.connected?
215
+
216
+ @request_mutex.synchronize do
217
+ wait(:SERIAL_PROXY_REQUEST_TYPE_FLUSH) do
218
+ device.send(Api::SerialProxyRequest.new(instance:,
219
+ type: :SERIAL_PROXY_REQUEST_TYPE_FLUSH))
220
+ end
221
+ end
222
+ end
223
+
224
+ def read(length, outbuf = +"")
225
+ readpartial(length, outbuf)
226
+ outbuf.concat(readpartial(length - outbuf.bytesize)) while outbuf.bytesize < length
227
+ outbuf
228
+ end
229
+
230
+ def readbyte
231
+ read(1)&.getbyte(0)
232
+ end
233
+ alias_method :getbyte, :readbyte
234
+
235
+ def readpartial(maxlen, outbuf = +"")
236
+ raise ArgumentError, "maxlen must be non-negative" unless maxlen >= 0
237
+
238
+ data = wait_for_data do
239
+ raise EOFError if @closed && @buffer.empty?
240
+ break +"".b if maxlen.zero?
241
+ break @buffer.slice!(0, maxlen) unless @buffer.empty?
242
+ end
243
+
244
+ apply_outbuf(outbuf, data)
245
+ end
246
+
247
+ def read_nonblock(maxlen, outbuf = +"", options = {})
248
+ raise ArgumentError, "maxlen must be non-negative" unless maxlen >= 0
249
+
250
+ if outbuf == ({ exception: false })
251
+ options = outbuf
252
+ outbuf = +""
253
+ end
254
+
255
+ return apply_outbuf(outbuf, +"".b) if maxlen.zero?
256
+
257
+ loop do
258
+ data = @mutex.synchronize do
259
+ raise EOFError if @closed && @buffer.empty?
260
+
261
+ @buffer.slice!(0, maxlen) unless @buffer.empty?
262
+ end
263
+ return apply_outbuf(outbuf, data) if data
264
+
265
+ result = wait_readable(0)
266
+ if result.nil?
267
+ raise WaitReadable unless options[:exception] == false
268
+
269
+ return :wait_readable
270
+ end
271
+
272
+ next
273
+ end
274
+ end
275
+
276
+ def wait_readable(timeout = nil)
277
+ return self unless @buffer.empty?
278
+
279
+ @mutex.synchronize do
280
+ return self unless @buffer.empty?
281
+
282
+ deadline = monotonic_time + timeout if timeout
283
+ Kernel.loop do
284
+ return nil if @closed || !device.connected?
285
+
286
+ remaining = deadline && (deadline - monotonic_time)
287
+ return nil if deadline && remaining <= 0
288
+
289
+ @data_ready.wait(@mutex, remaining)
290
+ return self unless @buffer.empty?
291
+ end
292
+ end
293
+ end
294
+
295
+ def ready?
296
+ !wait_readable(0).nil?
297
+ end
298
+
299
+ def ungetbyte(byte)
300
+ @mutex.synchronize do
301
+ @buffer.insert(0, Integer(byte).chr)
302
+ @data_ready.broadcast
303
+ end
304
+ end
305
+
306
+ def ungetc(char)
307
+ @mutex.synchronize do
308
+ @buffer.insert(0, String(char))
309
+ @data_ready.broadcast
310
+ end
311
+ end
312
+
313
+ def closed?
314
+ @mutex.synchronize { @closed }
315
+ end
316
+
317
+ private
318
+
319
+ def normalize_serial_proxy_parity(parity)
320
+ return parity if parity.to_s.start_with?("SERIAL_PROXY_PARITY_")
321
+
322
+ :"SERIAL_PROXY_PARITY_#{parity.to_s.upcase}"
323
+ end
324
+
325
+ def wait(request_type)
326
+ @queue.clear
327
+ yield
328
+ message = @queue.pop
329
+ raise EOFError unless message
330
+
331
+ unless message.type == request_type
332
+ raise UnexpectedMessage,
333
+ "Unexpected serial proxy response to request type #{message.type}"
334
+ end
335
+
336
+ case message.status
337
+ when :SERIAL_PROXY_STATUS_OK, :SERIAL_PROXY_STATUS_ASSUMED_SUCCESS
338
+ message.status
339
+ when :SERIAL_PROXY_STATUS_TIMEOUT
340
+ raise Timeout::Error
341
+ when :SERIAL_PROXY_STATUS_NOT_SUPPORTED
342
+ raise NotImplementedError
343
+ when :SERIAL_PROXY_STATUS_ERROR
344
+ raise SerialProxyError, message.error_message
345
+ else
346
+ raise SerialProxyError, "Unrecognized serial proxy status #{message.status.inspect}"
347
+ end
348
+ end
349
+
350
+ def apply_outbuf(outbuf, data)
351
+ return data unless outbuf
352
+
353
+ outbuf.replace(data)
354
+ end
355
+
356
+ def wait_for_data
357
+ @mutex.synchronize do
358
+ Kernel.loop do
359
+ raise EOFError if @closed
360
+
361
+ result = yield
362
+ return result unless result.nil?
363
+
364
+ @data_ready.wait(@mutex)
365
+ end
366
+ end
367
+ end
368
+
369
+ def monotonic_time
370
+ Process.clock_gettime(Process::CLOCK_MONOTONIC)
371
+ end
372
+ end
373
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ESPHome
4
+ VERSION = "1.1.1"
5
+ end
data/lib/esphome.rb ADDED
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "esphome/error"
4
+ require_relative "esphome/device"
5
+ require_relative "esphome/version"
@@ -0,0 +1,62 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "forwardable"
4
+ require "websocket/driver"
5
+ require "websocket/driver/httpx"
6
+
7
+ module HTTPX
8
+ module Plugins
9
+ module Websocket
10
+ class << self
11
+ def load_dependencies(klass)
12
+ klass.plugin(:upgrade)
13
+ end
14
+
15
+ def call(connection, request, response)
16
+ return unless (driver = request.driver)
17
+
18
+ return unless driver.send(:validate_handshake, response.headers)
19
+
20
+ connection.hijack_io
21
+ response.instance_variable_set(:@driver, driver)
22
+ driver.instance_variable_set(:@initial_response, response.body.to_s)
23
+ end
24
+
25
+ def extra_options(options)
26
+ options.merge(max_concurrent_requests: 1,
27
+ upgrade_handlers: options.upgrade_handlers.merge("websocket" => self))
28
+ end
29
+ end
30
+
31
+ module ConnectionMethods
32
+ def send(request)
33
+ request.init_websocket(self) unless request.driver || @upgrade_protocol
34
+
35
+ super
36
+ end
37
+ end
38
+
39
+ module RequestMethods
40
+ attr_reader :driver
41
+
42
+ def init_websocket(connection)
43
+ if connection.state == :open
44
+ socket = connection.to_io
45
+ @driver = WebSocket::Driver::HTTPX.new(socket, @headers, { masking: true })
46
+ else
47
+ connection.once(:open) do
48
+ socket = connection.to_io
49
+ @driver = WebSocket::Driver::HTTPX.new(socket, @headers, { masking: true })
50
+ end
51
+ end
52
+ end
53
+ end
54
+
55
+ module ResponseMethods
56
+ attr_reader :driver
57
+ end
58
+ end
59
+
60
+ register_plugin(:websocket, Websocket)
61
+ end
62
+ end
@@ -0,0 +1,74 @@
1
+ # frozen_string_literal: true
2
+
3
+ module WebSocket
4
+ class Driver
5
+ # WebSocket driver with HTTPX handling the HTTP negotiation
6
+ class HTTPX < Hybi
7
+ def initialize(socket, headers, options = {})
8
+ super(socket, options)
9
+ @headers = headers
10
+ @closed = false
11
+
12
+ @key = Client.generate_key
13
+ @accept = Driver::Hybi.generate_accept(@key)
14
+
15
+ @headers["upgrade"] = "websocket"
16
+ @headers["connection"] = "Upgrade"
17
+ @headers["sec-websocket-key"] = @key
18
+ @headers["sec-websocket-version"] = VERSION
19
+
20
+ @headers["Sec-WebSocket-Protocol"] = @protocols * ", " if @protocols.size.positive?
21
+
22
+ extensions = @extensions.generate_offer
23
+ @headers["Sec-WebSocket-Extensions"] = extensions if extensions
24
+ end
25
+
26
+ def start
27
+ open
28
+ parse(@initial_response)
29
+ end
30
+
31
+ def run
32
+ closed = false
33
+ on(:close) do
34
+ closed = true
35
+ end
36
+
37
+ until closed
38
+ bytes = @socket.read(1)
39
+ parse(bytes)
40
+ end
41
+ end
42
+
43
+ private
44
+
45
+ def fail_handshake(message) # rubocop:disable Naming/PredicateMethod
46
+ message = "Error during WebSocket handshake: #{message}"
47
+ @ready_state = 3
48
+ emit(:error, message)
49
+ emit(:close, Driver::CloseEvent.new(Driver::Hybi::ERRORS[:protocol_error], message))
50
+ false
51
+ end
52
+
53
+ def validate_handshake(headers)
54
+ accept = headers["sec-websocket-accept"]
55
+ protocol = headers["sec-websocket-protocol"]
56
+
57
+ return fail_handshake("Sec-WebSocket-Accept mismatch") unless accept == @accept
58
+
59
+ if protocol && !protocol.empty?
60
+ return fail_handshake("Sec-WebSocket-Protocol mismatch") unless @protocols.include?(protocol)
61
+
62
+ @protocol = protocol
63
+ end
64
+
65
+ begin
66
+ @extensions.activate(@headers["Sec-WebSocket-Extensions"])
67
+ rescue ::WebSocket::Extensions::ExtensionError => e
68
+ return fail_handshake(e.message)
69
+ end
70
+ true
71
+ end
72
+ end
73
+ end
74
+ end
metadata CHANGED
@@ -1,13 +1,13 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: esphome
3
3
  version: !ruby/object:Gem::Version
4
- version: 1.0.0
4
+ version: 1.1.1
5
5
  platform: ruby
6
6
  authors:
7
7
  - Cody Cutrer
8
8
  bindir: exe
9
9
  cert_chain: []
10
- date: 2026-07-20 00:00:00.000000000 Z
10
+ date: 2026-07-26 00:00:00.000000000 Z
11
11
  dependencies:
12
12
  - !ruby/object:Gem::Dependency
13
13
  name: curses
@@ -97,10 +97,62 @@ email:
97
97
  - cody@cutrer.us
98
98
  executables:
99
99
  - esphome-monitor
100
+ - esphome-serial-proxy
100
101
  extensions: []
101
102
  extra_rdoc_files: []
102
103
  files:
103
104
  - exe/esphome-monitor
105
+ - exe/esphome-serial-proxy
106
+ - lib/esphome.rb
107
+ - lib/esphome/action.rb
108
+ - lib/esphome/api.rb
109
+ - lib/esphome/api/README.md
110
+ - lib/esphome/api/api.proto
111
+ - lib/esphome/api/api_options.proto
112
+ - lib/esphome/api/api_options_pb.rb
113
+ - lib/esphome/api/api_pb.rb
114
+ - lib/esphome/cli/colors.rb
115
+ - lib/esphome/cli/completion.rb
116
+ - lib/esphome/cli/entities/button.rb
117
+ - lib/esphome/cli/entities/climate.rb
118
+ - lib/esphome/cli/entities/cover.rb
119
+ - lib/esphome/cli/entities/date.rb
120
+ - lib/esphome/cli/entities/date_time.rb
121
+ - lib/esphome/cli/entities/form.rb
122
+ - lib/esphome/cli/entities/lock.rb
123
+ - lib/esphome/cli/entities/menu.rb
124
+ - lib/esphome/cli/entities/number.rb
125
+ - lib/esphome/cli/entities/select.rb
126
+ - lib/esphome/cli/entities/subfields.rb
127
+ - lib/esphome/cli/entities/switch.rb
128
+ - lib/esphome/cli/entities/text.rb
129
+ - lib/esphome/cli/entities/time.rb
130
+ - lib/esphome/cli/entity.rb
131
+ - lib/esphome/cli/monitor.rb
132
+ - lib/esphome/dashboard.rb
133
+ - lib/esphome/device.rb
134
+ - lib/esphome/entities/binary_sensor.rb
135
+ - lib/esphome/entities/button.rb
136
+ - lib/esphome/entities/climate.rb
137
+ - lib/esphome/entities/cover.rb
138
+ - lib/esphome/entities/date.rb
139
+ - lib/esphome/entities/date_time.rb
140
+ - lib/esphome/entities/fan.rb
141
+ - lib/esphome/entities/light.rb
142
+ - lib/esphome/entities/lock.rb
143
+ - lib/esphome/entities/number.rb
144
+ - lib/esphome/entities/select.rb
145
+ - lib/esphome/entities/sensor.rb
146
+ - lib/esphome/entities/switch.rb
147
+ - lib/esphome/entities/text.rb
148
+ - lib/esphome/entities/text_sensor.rb
149
+ - lib/esphome/entities/time.rb
150
+ - lib/esphome/entity.rb
151
+ - lib/esphome/error.rb
152
+ - lib/esphome/serial_proxy.rb
153
+ - lib/esphome/version.rb
154
+ - lib/httpx/plugins/websocket.rb
155
+ - lib/websocket/driver/httpx.rb
104
156
  homepage: https://github.com/ccutrer/esphome-ruby
105
157
  licenses:
106
158
  - MIT