meshtastic 0.0.179 → 0.0.181

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 (71) hide show
  1. checksums.yaml +4 -4
  2. data/Gemfile +2 -2
  3. data/documentation/README.md +3 -2
  4. data/documentation/admin-channel.md +64 -0
  5. data/documentation/admin-config.md +59 -0
  6. data/documentation/admin-firmware-nordic.md +86 -0
  7. data/documentation/admin-firmware-serial.md +180 -0
  8. data/documentation/admin-firmware.md +126 -0
  9. data/documentation/admin.md +88 -35
  10. data/documentation/atak.md +93 -10
  11. data/documentation/channel.md +1 -28
  12. data/documentation/config.md +1 -27
  13. data/documentation/mesh-interface.md +7 -0
  14. data/documentation/meshtastic.md +2 -2
  15. data/lib/meshtastic/admin/channel.rb +164 -0
  16. data/lib/meshtastic/admin/config.rb +213 -0
  17. data/lib/meshtastic/admin/firmware/ble.rb +207 -0
  18. data/lib/meshtastic/admin/firmware/nordic_dfu.rb +218 -0
  19. data/lib/meshtastic/admin/firmware/serial_bootloader.rb +233 -0
  20. data/lib/meshtastic/admin/firmware.rb +288 -0
  21. data/lib/meshtastic/admin.rb +688 -34
  22. data/lib/meshtastic/apponly.rb +19 -7
  23. data/lib/meshtastic/atak.rb +295 -17
  24. data/lib/meshtastic/bluetooth/bluez.rb +20 -1
  25. data/lib/meshtastic/bluetooth.rb +70 -59
  26. data/lib/meshtastic/cannedmessages.rb +13 -6
  27. data/lib/meshtastic/channel.rb +3 -25
  28. data/lib/meshtastic/clientonly.rb +19 -7
  29. data/lib/meshtastic/config.rb +3 -24
  30. data/lib/meshtastic/config_pb.rb +2 -1
  31. data/lib/meshtastic/connection_status.rb +12 -5
  32. data/lib/meshtastic/deviceonly.rb +19 -7
  33. data/lib/meshtastic/localonly.rb +19 -7
  34. data/lib/meshtastic/mesh_interface.rb +19 -0
  35. data/lib/meshtastic/module_config.rb +13 -7
  36. data/lib/meshtastic/mqtt.rb +72 -35
  37. data/lib/meshtastic/paxcount.rb +17 -6
  38. data/lib/meshtastic/portnums.rb +13 -6
  39. data/lib/meshtastic/position.rb +16 -6
  40. data/lib/meshtastic/remote_hardware.rb +27 -11
  41. data/lib/meshtastic/rtttl.rb +17 -8
  42. data/lib/meshtastic/serial.rb +81 -70
  43. data/lib/meshtastic/storeforward.rb +13 -6
  44. data/lib/meshtastic/storeforward_pb.rb +1 -1
  45. data/lib/meshtastic/stream_interface.rb +11 -0
  46. data/lib/meshtastic/tcp.rb +73 -22
  47. data/lib/meshtastic/telemetry.rb +15 -6
  48. data/lib/meshtastic/traceroute.rb +15 -6
  49. data/lib/meshtastic/util.rb +3 -1
  50. data/lib/meshtastic/version.rb +1 -1
  51. data/lib/meshtastic/xmodem.rb +12 -5
  52. data/lib/meshtastic.rb +22 -3
  53. data/spec/conventions_spec.rb +321 -0
  54. data/spec/lib/meshtastic/admin/channel_spec.rb +202 -0
  55. data/spec/lib/meshtastic/admin/config_spec.rb +114 -0
  56. data/spec/lib/meshtastic/admin/firmware/ble_spec.rb +170 -0
  57. data/spec/lib/meshtastic/admin/firmware/nordic_dfu_spec.rb +248 -0
  58. data/spec/lib/meshtastic/admin/firmware/serial_bootloader_spec.rb +263 -0
  59. data/spec/lib/meshtastic/admin/firmware_spec.rb +315 -0
  60. data/spec/lib/meshtastic/admin_spec.rb +412 -1
  61. data/spec/lib/meshtastic/apponly_spec.rb +1 -1
  62. data/spec/lib/meshtastic/atak_spec.rb +129 -4
  63. data/spec/lib/meshtastic/bluetooth_spec.rb +1 -1
  64. data/spec/lib/meshtastic/channel_spec.rb +2 -20
  65. data/spec/lib/meshtastic/config_spec.rb +2 -20
  66. data/spec/lib/meshtastic/connection_status_spec.rb +1 -1
  67. data/spec/lib/meshtastic/deviceonly_spec.rb +1 -1
  68. data/spec/lib/meshtastic/localonly_spec.rb +1 -1
  69. data/spec/lib/meshtastic/mesh_interface_spec.rb +31 -0
  70. data/spec/lib/meshtastic/portnums_spec.rb +2 -2
  71. metadata +24 -6
@@ -0,0 +1,248 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'spec_helper'
4
+ require 'json'
5
+ require 'zlib'
6
+ require 'meshtastic/admin/firmware/nordic_dfu' if File.exist?(File.expand_path('../../../../../lib/meshtastic/admin/firmware/nordic_dfu.rb', __dir__))
7
+
8
+ # A stateful byte-level peer, not canned success responses. It enforces the
9
+ # SDK11 command order, image sizing, PRN cadence, and independent image CRC.
10
+ class NordicDFUPeer
11
+ CONTROL = '00001531-1212-efde-1523-785feabcd123'
12
+ PACKET = '00001532-1212-efde-1523-785feabcd123'
13
+ attr_reader :image, :activated, :closed, :writes
14
+ attr_accessor :fault
15
+
16
+ def initialize
17
+ @state = :idle
18
+ @events = []
19
+ @image = ''.b
20
+ @init = ''.b
21
+ @writes = []
22
+ @count = 0
23
+ end
24
+
25
+ def subscribe(uuid:)
26
+ raise 'wrong subscription' unless uuid == CONTROL
27
+
28
+ @subscribed = true
29
+ end
30
+
31
+ def write(uuid:, bytes:, response:)
32
+ raise 'not subscribed' unless @subscribed
33
+
34
+ @writes << [uuid, bytes, response]
35
+ if uuid == CONTROL
36
+ raise 'control requires write response' unless response
37
+
38
+ control(bytes)
39
+ else
40
+ raise 'invalid packet write' unless uuid == PACKET && !response && bytes.bytesize <= 20
41
+
42
+ packet(bytes)
43
+ end
44
+ end
45
+
46
+ def control(bytes)
47
+ case [@state, bytes.bytes]
48
+ when [:idle, [1, 4]] then @state = :size
49
+ when [:started, [2, 0]] then @state = :init
50
+ when [:init, [2, 1]]
51
+ raise 'bad init' unless @init.bytesize == 14 && @init.unpack1('v') == 0x52
52
+
53
+ reply(2)
54
+ @state = :initialized
55
+ when [:ready, [3]] then @state = :image
56
+ when [:received, [4]]
57
+ @image.setbyte(0, @image.getbyte(0) ^ 1) if @fault == :flash_corruption
58
+ reply(4, crc(@image) == @init.byteslice(-2, 2).unpack1('v') ? 1 : 5)
59
+ @state = :validated
60
+ when [:validated, [5]] then @activated = true
61
+ else
62
+ raise "unexpected command #{@state}: #{bytes.bytes}" unless @state == :initialized && bytes.getbyte(0) == 8 && bytes.bytesize == 3
63
+
64
+ @interval = bytes.byteslice(1, 2).unpack1('v')
65
+ raise 'receipts disabled' unless @interval.positive?
66
+
67
+ @state = :ready
68
+ end
69
+ end
70
+
71
+ def packet(bytes)
72
+ case @state
73
+ when :size
74
+ sd, bl, @size = bytes.unpack('V3')
75
+ raise 'bad image sizes' unless bytes.bytesize == 12 && sd.zero? && bl.zero? && @size.positive?
76
+
77
+ @state = :started
78
+ reply(1)
79
+ when :init then @init << bytes
80
+ when :image
81
+ raise 'unaligned image packet' unless (bytes.bytesize % 4).zero?
82
+
83
+ @image << bytes
84
+ raise 'image overflow' if @image.bytesize > @size
85
+
86
+ @count += 1
87
+ if @image.bytesize == @size
88
+ @state = :received
89
+ reply(3)
90
+ elsif (@count % @interval).zero?
91
+ receipt = @image.bytesize + (@fault == :receipt ? 4 : 0)
92
+ @events << [17, receipt].pack('CV')
93
+ end
94
+ else raise "packet in #{@state}"
95
+ end
96
+ end
97
+
98
+ def reply(opcode, status = 1)
99
+ status = 6 if @fault == :reject && opcode == 2
100
+ opcode = 4 if @fault == :wrong_opcode && opcode == 1
101
+ @events << [16, opcode, status].pack('C*')
102
+ end
103
+
104
+ def notification(timeout:)
105
+ raise 'unbounded wait' unless timeout.positive?
106
+ return nil if @fault == :timeout
107
+
108
+ bytes = @events.shift
109
+ raise 'client waited when peer owes no response' unless bytes
110
+
111
+ { uuid: CONTROL, bytes: bytes }
112
+ end
113
+
114
+ def close
115
+ @closed = true
116
+ end
117
+
118
+ def crc(bytes)
119
+ crc = 0xffff
120
+ bytes.each_byte do |byte|
121
+ crc ^= byte << 8
122
+ 8.times { crc = ((crc << 1) ^ (crc.anybits?(0x8000) ? 0x1021 : 0)) & 0xffff }
123
+ end
124
+ crc
125
+ end
126
+ end
127
+
128
+ RSpec.describe 'Meshtastic::Admin::Firmware::NordicDFU' do
129
+ let(:installer) { Meshtastic::Admin::Firmware::NordicDFU }
130
+ let(:peer) { NordicDFUPeer.new }
131
+ let(:image) { (0...244).to_a.pack('C*') }
132
+ let(:init) { [0x52, 0xffff, 1, 1, 0xfffe, peer.crc(image)].pack('vvVvvv') }
133
+ let(:manifest) { { manifest: { application: { bin_file: 'app.bin', dat_file: 'app.dat' } } } }
134
+ let(:entries) { { 'manifest.json' => JSON.generate(manifest), 'app.bin' => image, 'app.dat' => init } }
135
+
136
+ def zip(entries, compression: 0)
137
+ local = ''.b
138
+ central = ''.b
139
+ entries.each do |name, bytes|
140
+ raw = if compression == 8
141
+ deflater = Zlib::Deflate.new(Zlib::DEFAULT_COMPRESSION, -Zlib::MAX_WBITS)
142
+ begin
143
+ deflater.deflate(bytes, Zlib::FINISH)
144
+ ensure
145
+ deflater.close
146
+ end
147
+ else
148
+ bytes
149
+ end
150
+ crc = Zlib.crc32(bytes)
151
+ central << [0x02014b50, 20, 20, 0, compression, 0, 0, crc, raw.bytesize, bytes.bytesize,
152
+ name.bytesize, 0, 0, 0, 0, 0, local.bytesize].pack('VvvvvvvVVVvvvvvVV') << name
153
+ local << [0x04034b50, 20, 0, compression, 0, 0, crc, raw.bytesize, bytes.bytesize,
154
+ name.bytesize, 0].pack('VvvvvvVVVvv') << name << raw
155
+ end
156
+ local + central + [0x06054b50, 0, 0, entries.length, entries.length, central.bytesize, local.bytesize, 0].pack('VvvvvVVv')
157
+ end
158
+
159
+ {
160
+ 'image CRC mismatch' => ->(entries) { entries.merge('app.bin' => 'x' * 244) },
161
+ 'unaligned image' => ->(entries) { entries.merge('app.bin' => 'x') },
162
+ 'empty image' => ->(entries) { entries.merge('app.bin' => '') },
163
+ 'signed extension' => ->(entries) { entries.merge('app.dat' => entries.fetch('app.dat').byteslice(0, 12) + [2, 244].pack('V2') + ('x' * 96)) },
164
+ 'secure protobuf init' => ->(entries) { entries.merge('app.dat' => "\x12\x08\x0a\x06secure".b) },
165
+ 'bootloader update' => ->(entries) { entries.merge('manifest.json' => JSON.generate(manifest: { bootloader: { bin_file: 'app.bin', dat_file: 'app.dat' } })) },
166
+ 'traversal entry' => ->(entries) { entries.merge('../outside' => 'unsafe') },
167
+ 'missing init' => ->(entries) { entries.except('app.dat') }
168
+ }.each do |description, mutate|
169
+ it "rejects #{description} before any bootloader writes" do
170
+ expect { installer.install(package_bytes: zip(mutate.call(entries)), gatt: peer) }.to raise_error(ArgumentError)
171
+ expect(peer.writes).to be_empty
172
+ end
173
+ end
174
+
175
+ it 'rejects truncated ZIP data without writing to the bootloader' do
176
+ expect { installer.install(package_bytes: zip(entries).byteslice(0, 100), gatt: peer) }.to raise_error(ArgumentError)
177
+ expect(peer.writes).to be_empty
178
+ end
179
+
180
+ it 'reads a package file without requiring any external unpacker' do
181
+ require 'tempfile'
182
+ Tempfile.create(['nordic', '.zip']) do |file|
183
+ file.binmode
184
+ file.write(zip(entries))
185
+ file.flush
186
+ installer.install(package: file.path, gatt: peer)
187
+ expect(peer.activated).to be(true)
188
+ end
189
+ end
190
+
191
+ it 'rejects ambiguous package sources before touching GATT' do
192
+ expect { installer.install(package_bytes: zip(entries), package: '/unused.zip', gatt: peer) }.to raise_error(ArgumentError, /exactly one/)
193
+ expect(peer.writes).to be_empty
194
+ end
195
+
196
+ it 'rejects unknown options rather than ignoring secure or retry requests' do
197
+ expect { installer.install(package_bytes: zip(entries), gatt: peer, secure: true) }.to raise_error(ArgumentError, /Unsupported.*secure/)
198
+ expect(peer.writes).to be_empty
199
+ end
200
+
201
+ it 'rejects a JSON scalar manifest with an operator-usable error' do
202
+ malformed = entries.merge('manifest.json' => JSON.generate('not a manifest'))
203
+ expect { installer.install(package_bytes: zip(malformed), gatt: peer) }.to raise_error(ArgumentError, /manifest/)
204
+ expect(peer.writes).to be_empty
205
+ end
206
+
207
+ it 'rejects malformed manifest application types clearly before I/O' do
208
+ malformed = entries.merge('manifest.json' => JSON.generate(manifest: { application: 'oops' }))
209
+ expect { installer.install(package_bytes: zip(malformed), gatt: peer) }.to raise_error(ArgumentError, /application/)
210
+ expect(peer.writes).to be_empty
211
+ end
212
+
213
+ %i[receipt reject wrong_opcode timeout flash_corruption].each do |fault|
214
+ it "fails closed on #{fault} without replaying unsequenced packets" do
215
+ peer.fault = fault
216
+ expect { installer.install(package_bytes: zip(entries), gatt: peer) }.to raise_error(fault == :timeout ? Timeout::Error : IOError)
217
+ expect(peer.activated).not_to be(true)
218
+ expect(peer.closed).to be(true)
219
+ expect(peer.writes.count { |_uuid, bytes, _response| bytes == [1, 4].pack('C*') }).to eq(1)
220
+ end
221
+ end
222
+
223
+ it 'connects the shared production BlueZ backend to the legacy service by default' do
224
+ require 'meshtastic/admin/firmware/ble'
225
+ expect(Meshtastic::Admin::Firmware::BLE::BlueZ).to receive(:new).with(
226
+ address: 'AA:BB:CC:DD:EE:FF', adapter: 'hci0', timeout: 30,
227
+ service_uuid: '00001530-1212-efde-1523-785feabcd123'
228
+ ).and_return(peer)
229
+ expect(peer).to receive(:connect).and_return(peer)
230
+ installer.install(package_bytes: zip(entries), address: 'AA:BB:CC:DD:EE:FF')
231
+ expect(peer.activated).to be(true)
232
+ end
233
+
234
+ it 'reads a deflated ZIP generated by normal DFU package tools' do
235
+ installer.install(package_bytes: zip(entries, compression: 8), gatt: peer)
236
+ expect(peer.image).to eq(image)
237
+ expect(peer.activated).to be(true)
238
+ end
239
+
240
+ it 'installs a legacy application ZIP through real command and receipt states' do
241
+ expect(defined?(Meshtastic::Admin::Firmware::NordicDFU)).to eq('constant')
242
+ result = installer.install(package_bytes: zip(entries), gatt: peer)
243
+ expect(peer.image).to eq(image)
244
+ expect(peer.activated).to be(true)
245
+ expect(peer.closed).to be(true)
246
+ expect(result).to include(status: :verified, bytes: image.bytesize, protocol: :nordic_dfu, reboot_verified: false)
247
+ end
248
+ end
@@ -0,0 +1,263 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'spec_helper'
4
+ require 'pty'
5
+ require 'digest'
6
+ require 'timeout'
7
+ require 'tempfile'
8
+ require 'meshtastic/admin/firmware/serial_bootloader'
9
+
10
+ RSpec.describe Meshtastic::Admin::Firmware::SerialBootloader do # rubocop:disable Metrics/BlockLength
11
+ def image(chip_id = 0)
12
+ header = [0xe9, 1, 2, 0x20, 0x40000000].pack('C4V')
13
+ header << [0xee, 0, 0, 0, chip_id, 0, 0, 0].pack('C4vCvv') << "\0\0\0\0\1".b
14
+ data = ("\xc0\xdb".b * 600)
15
+ body = header + [0x3ffb0000, data.bytesize].pack('V2') + data
16
+ body << "\0" until body.bytesize % 16 == 15
17
+ body << [data.bytes.reduce(0xef, :^)].pack('C')
18
+ body + Digest::SHA256.digest(body)
19
+ end
20
+
21
+ def frame(packet)
22
+ "\xc0".b + packet.bytes.map { |b|
23
+ if b == 0xc0
24
+ "\xdb\xdc".b
25
+ else
26
+ b == 0xdb ? "\xdb\xdd".b : b.chr
27
+ end
28
+ }.join.b + "\xc0".b
29
+ end
30
+
31
+ def receive_packet(io)
32
+ bytes = +''.b
33
+ started = false
34
+ loop do
35
+ b = io.readpartial(1).getbyte(0)
36
+ if b == 0xc0
37
+ return bytes.gsub("\xdb\xdc".b, "\xc0".b).gsub("\xdb\xdd".b, "\xdb".b) if started && !bytes.empty?
38
+
39
+ started = true
40
+ elsif started
41
+ bytes << b
42
+ end
43
+ end
44
+ end
45
+
46
+ def emulate(bytes:, chip_id: 0, failure: nil)
47
+ PTY.open do |master, slave|
48
+ commands = []
49
+ flash = +''.b
50
+ worker = Thread.new do
51
+ Thread.current.report_on_exception = false
52
+ loop do
53
+ packet = receive_packet(master)
54
+ direction, op, length, checksum = packet.unpack('CCvV')
55
+ payload = packet.byteslice(8..)
56
+ raise 'bad request framing' unless direction.zero? && length == payload.bytesize
57
+
58
+ commands << op
59
+ value = 0
60
+ response = +''.b
61
+ case op
62
+ when 8
63
+ raise 'bad sync' unless payload == "\x07\x07\x12\x20".b + ("\x55" * 32)
64
+
65
+ value = failure == :stub ? 0 : 0x20100707
66
+ next if failure == :sync_once && commands.count(8) == 1
67
+ when 10
68
+ address = payload.unpack1('V')
69
+ value = address == 0x40001000 ? 0x00f01d83 : 0
70
+ value = 0 if failure == :wrong_chip
71
+ value = 0x30 if failure == :secure && address == 0x3ff5a018
72
+ when 0x14
73
+ response = ([failure == :secure ? 1 : 0] + ([0] * 8) + [failure == :wrong_chip ? 99 : chip_id, 0]).pack('VC8V2')
74
+ when 0x0d
75
+ raise 'bad attach' unless payload == [0, 0].pack('V2')
76
+ when 0x0b
77
+ raise 'bad geometry' unless payload == [0, 4 * 1024 * 1024, 65_536, 4096, 256, 65_535].pack('V6')
78
+ when 2
79
+ params = payload.unpack('V*')
80
+ expected = [bytes.bytesize, (bytes.bytesize + 1023) / 1024, 1024, 0x10000]
81
+ expected << 0 unless chip_id.zero?
82
+ raise "bad begin #{params}" unless params == expected
83
+ when 3
84
+ size, sequence, reserved1, reserved2 = payload.unpack('V4')
85
+ block = payload.byteslice(16..)
86
+ raise 'bad block' unless size == 1024 && block.bytesize == size && sequence * 1024 == flash.bytesize && reserved1.zero? && reserved2.zero?
87
+ raise 'bad checksum' unless checksum == block.bytes.reduce(0xef, :^)
88
+
89
+ flash << block
90
+ when 0x13
91
+ raise 'bad verify range' unless payload == [0x10000, bytes.bytesize, 0, 0].pack('V4')
92
+ raise 'different flashed bytes' unless flash.byteslice(0, bytes.bytesize) == bytes
93
+
94
+ response = failure == :digest ? '0' * 32 : Digest::MD5.hexdigest(flash.byteslice(0, bytes.bytesize))
95
+ when 4
96
+ raise 'bad end' unless payload == [0].pack('V')
97
+ else
98
+ raise "unexpected opcode #{op}"
99
+ end
100
+ status = failure == op ? [1, 5, 0, 0].pack('C4') : "\0" * 4
101
+ response += status
102
+ reply = frame([1, op, response.bytesize, value].pack('CCvV') + response)
103
+ if op == 3
104
+ next if failure == :silent
105
+
106
+ reply = "\xc0\xdb\x01\xc0".b if failure == :bad_escape
107
+ reply = frame([1, 99, 4, 0].pack('CCvV') + ("\0" * 4)) if failure == :wrong_opcode
108
+ reply = frame([1, op, 99, 0].pack('CCvV') + ("\0" * 4)) if failure == :bad_length
109
+ reply = frame('short') if failure == :short
110
+ end
111
+ (op == 8 ? 8 : 1).times { master.write(reply) }
112
+ break if op == 4 || failure == op || (op == 0x13 && failure == :digest)
113
+ end
114
+ rescue EOFError, Errno::EIO
115
+ nil
116
+ end
117
+ begin
118
+ yield slave.path, commands
119
+ ensure
120
+ worker.kill
121
+ worker.join
122
+ worker.value
123
+ end
124
+ end
125
+ end
126
+
127
+ let(:installer) { Meshtastic::Admin::Firmware::SerialBootloader }
128
+ let(:bytes) { image }
129
+
130
+ it 'rejects invalid chip, image, flash geometry and options before opening any port' do
131
+ base = { port: '/dev/never-open', chip: :esp32, bytes: bytes, offset: 0x10000, flash_size: 4 * 1024 * 1024, reset: :none }
132
+ corrupt = bytes.dup
133
+ corrupt.setbyte(35, corrupt.getbyte(35) ^ 1)
134
+ corrupt_hash = bytes.dup
135
+ corrupt_hash.setbyte(-1, corrupt_hash.getbyte(-1) ^ 1)
136
+ invalid = [
137
+ { chip: :esp8266 }, { bytes: 'bad' }, { bytes: image(9) }, { bytes: corrupt },
138
+ { bytes: corrupt_hash },
139
+ { bytes: "#{bytes}junk" }, { bytes: bytes.byteslice(0, 35) },
140
+ { offset: -1 }, { offset: 0x10001 }, { offset: 0 }, { offset: 4 * 1024 * 1024 },
141
+ { flash_size: 123 }, { timeout: 0 }, { timeout: Float::INFINITY },
142
+ { reset: :magic }, { protocol: :nordic_serial }, { firmware: '/tmp/ambiguous' },
143
+ { surprise: true }, { port: '' }
144
+ ]
145
+ expect(UART).not_to receive(:open)
146
+ invalid.each do |change|
147
+ expect { installer.install(base.merge(change)) }.to raise_error(ArgumentError), change.inspect
148
+ end
149
+ end
150
+
151
+ { esp32s3: 9, esp32c3: 5 }.each do |chip, id|
152
+ it "identifies #{chip} with GET_SECURITY_INFO and uses extended FLASH_BEGIN" do
153
+ firmware = image(id)
154
+ emulate(bytes: firmware, chip_id: id) do |port, commands|
155
+ result = installer.install(port: port, chip: chip, bytes: firmware, offset: 0x10000, flash_size: 4 * 1024 * 1024, reset: :none)
156
+ expect(result[:chip]).to eq(chip)
157
+ expect(commands).to eq([8, 20, 13, 11, 2, 3, 3, 19, 4])
158
+ end
159
+ end
160
+ end
161
+
162
+ it 'retries only synchronization when the ROM misses its first request' do
163
+ emulate(bytes: bytes, failure: :sync_once) do |port, commands|
164
+ result = installer.install(port: port, chip: :esp32, bytes: bytes, offset: 0x10000, flash_size: 4 * 1024 * 1024, reset: :none, timeout: 0.1)
165
+ expect(result[:status]).to eq(:verified)
166
+ expect(commands.count(8)).to eq(2)
167
+ expect(commands.count(2)).to eq(1)
168
+ end
169
+ end
170
+
171
+ it 'rejects an already running flasher stub before any flash command' do
172
+ emulate(bytes: bytes, failure: :stub) do |port, commands|
173
+ expect { installer.install(port: port, chip: :esp32, bytes: bytes, offset: 0x10000, flash_size: 4 * 1024 * 1024, reset: :none) }.to raise_error(IOError, /stub/)
174
+ expect(commands).to eq([8])
175
+ end
176
+ end
177
+
178
+ [3, :digest, :bad_escape, :wrong_opcode, :bad_length, :short, :silent].each do |failure|
179
+ it "fails closed on #{failure} without reboot or replaying FLASH_DATA and closes its UART" do
180
+ opened = nil
181
+ allow(UART).to(receive(:open).and_wrap_original { |original, *args| opened = original.call(*args) })
182
+ emulate(bytes: bytes, failure: failure) do |port, commands|
183
+ error = failure == :silent ? Timeout::Error : IOError
184
+ expect { installer.install(port: port, chip: :esp32, bytes: bytes, offset: 0x10000, flash_size: 4 * 1024 * 1024, reset: :none, timeout: 0.1) }.to raise_error(error)
185
+ expect(commands).not_to include(4)
186
+ expect(commands.count(3)).to eq(failure == :digest ? 2 : 1)
187
+ end
188
+ expect(opened).to be_closed
189
+ end
190
+ end
191
+
192
+ { esp32: 0, esp32s3: 9, esp32c3: 5 }.each do |chip, id|
193
+ %i[secure wrong_chip].each do |failure|
194
+ it "rejects #{failure} on #{chip} before erasing any flash" do
195
+ firmware = image(id)
196
+ emulate(bytes: firmware, chip_id: id, failure: failure) do |port, commands|
197
+ expect { installer.install(port: port, chip: chip, bytes: firmware, offset: 0x10000, flash_size: 4 * 1024 * 1024, reset: :none) }.to raise_error(IOError)
198
+ expect(commands).not_to include(2, 3)
199
+ end
200
+ end
201
+ end
202
+ end
203
+
204
+ it 'reads an image file and accepts the ESP format without an appended digest' do
205
+ firmware = bytes.byteslice(0, bytes.bytesize - 32)
206
+ firmware.setbyte(23, 0)
207
+ Tempfile.create(['esp-app', '.bin']) do |file|
208
+ file.binmode
209
+ file.write(firmware)
210
+ file.flush
211
+ emulate(bytes: firmware) do |port, _commands|
212
+ result = installer.install(port: port, chip: :esp32, firmware: file.path, offset: 0x10000, flash_size: 4 * 1024 * 1024, reset: :none)
213
+ expect(result[:md5]).to eq(Digest::MD5.hexdigest(firmware))
214
+ end
215
+ end
216
+ end
217
+
218
+ it 'clears inherited hardware flow control and hangup reset before writing ROM bytes' do
219
+ allow(UART).to receive(:open).and_wrap_original do |original, *args|
220
+ serial = original.call(*args)
221
+ attrs = Termios.tcgetattr(serial)
222
+ attrs.cflag |= Termios::CRTSCTS | Termios::HUPCL
223
+ Termios.tcsetattr(serial, Termios::TCSANOW, attrs)
224
+ allow(serial).to receive(:write).and_wrap_original do |write, *data|
225
+ expect(Termios.tcgetattr(serial).cflag & (Termios::CRTSCTS | Termios::HUPCL)).to eq(0)
226
+ write.call(*data)
227
+ end
228
+ serial
229
+ end
230
+ emulate(bytes: bytes) do |port, _commands|
231
+ installer.install(port: port, chip: :esp32, bytes: bytes, offset: 0x10000, flash_size: 4 * 1024 * 1024, reset: :none)
232
+ end
233
+ end
234
+
235
+ it 'uses classic DTR/RTS boot reset and a final EN pulse on a dedicated UART' do
236
+ controls = []
237
+ opened = nil
238
+ allow(UART).to receive(:open).and_wrap_original do |original, *args|
239
+ opened = original.call(*args)
240
+ allow(opened).to receive(:ioctl) { |op, bits| controls << [op, bits.unpack1('i')] }
241
+ opened
242
+ end
243
+ allow(installer).to receive(:sleep)
244
+ emulate(bytes: bytes) do |port, _commands|
245
+ installer.install(port: port, chip: :esp32, bytes: bytes, offset: 0x10000, flash_size: 4 * 1024 * 1024)
246
+ end
247
+ expect(controls).to eq([
248
+ [Termios::TIOCMBIC, Termios::TIOCM_DTR], [Termios::TIOCMBIS, Termios::TIOCM_RTS],
249
+ [Termios::TIOCMBIS, Termios::TIOCM_DTR], [Termios::TIOCMBIC, Termios::TIOCM_RTS],
250
+ [Termios::TIOCMBIC, Termios::TIOCM_DTR],
251
+ [Termios::TIOCMBIS, Termios::TIOCM_RTS], [Termios::TIOCMBIC, Termios::TIOCM_RTS]
252
+ ])
253
+ expect(opened).to be_closed
254
+ end
255
+
256
+ it 'opens a real UART PTY, syncs, flashes checksummed blocks and verifies the device MD5 before reboot' do
257
+ emulate(bytes: bytes) do |port, commands|
258
+ result = installer.install(port: port, chip: :esp32, bytes: bytes, offset: 0x10000, flash_size: 4 * 1024 * 1024, reset: :none)
259
+ expect(result).to include(status: :verified, chip: :esp32, bytes: bytes.bytesize, md5: Digest::MD5.hexdigest(bytes), reboot_requested: true, boot_verified: false)
260
+ expect(commands).to eq([8, 10, 10, 10, 13, 11, 2, 3, 3, 19, 4])
261
+ end
262
+ end
263
+ end