meshtastic 0.0.187 → 0.0.188

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 (39) hide show
  1. checksums.yaml +4 -4
  2. data/documentation/README.md +2 -1
  3. data/documentation/admin-backup.md +82 -3
  4. data/documentation/admin-config.md +28 -1
  5. data/documentation/admin-firmware-migration.md +171 -0
  6. data/documentation/admin-firmware.md +7 -3
  7. data/documentation/admin.md +16 -0
  8. data/documentation/mesh-interface.md +46 -18
  9. data/documentation/mqtt.md +63 -71
  10. data/documentation/waypoint.md +54 -0
  11. data/lib/meshtastic/admin/backup.rb +44 -5
  12. data/lib/meshtastic/admin/channel.rb +3 -8
  13. data/lib/meshtastic/admin/config.rb +63 -1
  14. data/lib/meshtastic/admin/firmware.rb +43 -10
  15. data/lib/meshtastic/admin/validation.rb +78 -0
  16. data/lib/meshtastic/admin.rb +28 -22
  17. data/lib/meshtastic/mesh_interface.rb +123 -43
  18. data/lib/meshtastic/module_config.rb +2 -0
  19. data/lib/meshtastic/mqtt.rb +21 -12
  20. data/lib/meshtastic/version.rb +1 -1
  21. data/lib/meshtastic/waypoint.rb +85 -0
  22. data/lib/meshtastic.rb +1 -0
  23. data/spec/lib/meshtastic/admin/backup_spec.rb +153 -9
  24. data/spec/lib/meshtastic/admin/config_spec.rb +70 -1
  25. data/spec/lib/meshtastic/admin/firmware_migration_spec.rb +121 -0
  26. data/spec/lib/meshtastic/admin/firmware_spec.rb +1 -1
  27. data/spec/lib/meshtastic/admin/firmware_target_selection_spec.rb +44 -0
  28. data/spec/lib/meshtastic/admin/validation_spec.rb +89 -0
  29. data/spec/lib/meshtastic/admin_spec.rb +77 -0
  30. data/spec/lib/meshtastic/bluetooth_spec.rb +26 -1
  31. data/spec/lib/meshtastic/mesh_interface_spec.rb +54 -0
  32. data/spec/lib/meshtastic/module_config_spec.rb +1 -1
  33. data/spec/lib/meshtastic/mqtt_spec.rb +165 -0
  34. data/spec/lib/meshtastic/serial_spec.rb +26 -0
  35. data/spec/lib/meshtastic/tcp_spec.rb +29 -0
  36. data/spec/lib/meshtastic/waypoint_spec.rb +55 -0
  37. data/spec/support/channel_crypto_reference.rb +29 -0
  38. data/spec/support/firmware_release_artifact_audit.rb +39 -0
  39. metadata +11 -1
@@ -18,7 +18,7 @@ class BackupRadioPeer
18
18
  device: Meshtastic::Config.new(device: { serial_enabled: false }),
19
19
  network: Meshtastic::Config.new(network: { wifi_enabled: true, wifi_psk: 'password' }),
20
20
  mqtt: Meshtastic::ModuleConfig.new(mqtt: { enabled: false, password: 'broker-secret' }),
21
- channel: Meshtastic::Channel.new(index: 0, role: :PRIMARY, settings: { psk: "\x00\xff".b, name: 'Test' }),
21
+ channel: Meshtastic::Channel.new(index: 0, role: :PRIMARY, settings: { psk: "\x00\xff".b * 8, name: 'Test' }),
22
22
  ui: Meshtastic::DeviceUIConfig.new(calibration_data: "\x00\xff".b)
23
23
  }
24
24
  @handle = { serial_conn: self, my_node_num: 0xaabbccdd, from_radio_queue: Queue.new }
@@ -75,6 +75,150 @@ describe 'Meshtastic::Admin::Backup' do
75
75
  let(:peer) { BackupRadioPeer.new }
76
76
  let(:options) { { transport_obj: peer.handle, config_types: [:DEVICE_CONFIG], module_config_types: [:MQTT_CONFIG], channel_indexes: [0], include_ui: true, timeout: 0.3 } }
77
77
 
78
+ it 'rejects semantic errors in every backup format before any requests' do
79
+ invalid_messages = [Meshtastic::User.new(long_name: 'é' * 13),
80
+ Meshtastic::Channel.new(index: 0, settings: { psk: 'bad' }),
81
+ Meshtastic::Config.new(lora: { region: 999 })]
82
+ invalid_messages.zip([['owner', nil], ['channel', 0], %w[config LORA_CONFIG]]).each do |message, (section, slot)|
83
+ document = { 'format' => backup_api::FORMAT, 'version' => 1, 'warning' => '', 'records' => [
84
+ { 'section' => section, 'slot' => slot, 'value' => JSON.parse(message.class.encode_json(message, preserve_proto_fieldnames: true)) }
85
+ ] }
86
+ expect { backup_api.import(transport_obj: peer.handle, backup: document, dry_run: true) }.to raise_error(ArgumentError)
87
+ end
88
+ profile = Meshtastic::DeviceProfile.new(long_name: 'é' * 13)
89
+ expect { backup_api.import(transport_obj: peer.handle, backup: profile.to_proto) }.to raise_error(ArgumentError)
90
+ expect(peer.sent).to be_empty
91
+ end
92
+
93
+ it 'requires explicit identity-key restoration without conflating omitted owner metadata' do
94
+ profile = Meshtastic::DeviceProfile.new(config: { security: { private_key: 'k' * 32, public_key: 'p' * 32 } })
95
+ bytes = profile.to_proto
96
+ expect { backup_api.import(transport_obj: peer.handle, backup: bytes) }.to raise_error(ArgumentError, /identity_keys/)
97
+ expect(peer.sent).to be_empty
98
+ result = backup_api.import(transport_obj: peer.handle, backup: bytes, identity_keys: :restore, timeout: 0.3)
99
+ expect(result).to include(acknowledged: 1, persistence_verified: false, reconnect_required: true)
100
+ expect(result[:warnings].join).to match(/node number.*reconnect/i)
101
+ expect(peer.sent.last.set_config.security.private_key).to eq('k' * 32)
102
+ expect(bytes).to eq(profile.to_proto)
103
+ peer.sent.clear
104
+ clean = Meshtastic::DeviceProfile.new(config: { security: { serial_enabled: true } })
105
+ expect { backup_api.import(transport_obj: peer.handle, backup: clean.to_proto, dry_run: true) }.not_to raise_error
106
+ expect(peer.sent).to be_empty
107
+ end
108
+
109
+ it 'checks policy consistently for JSON security keys and channel sharing before edit requests' do
110
+ messages = [[Meshtastic::Config.new(security: { public_key: 'p' * 32 }), 'config', 'SECURITY_CONFIG', { identity_keys: :restore }],
111
+ [Meshtastic::Channel.new(settings: { uplink_enabled: true }), 'channel', 0, { allow_privacy_changes: true }]]
112
+ messages.each do |message, section, slot, consent|
113
+ document = { 'format' => backup_api::FORMAT, 'version' => 1, 'warning' => '', 'records' => [
114
+ { 'section' => section, 'slot' => slot, 'value' => JSON.parse(message.class.encode_json(message, preserve_proto_fieldnames: true)) }
115
+ ] }
116
+ expect { backup_api.import(transport_obj: peer.handle, backup: document, edit_transaction: true) }.to raise_error(ArgumentError)
117
+ report = backup_api.import({ transport_obj: peer.handle, backup: document, dry_run: true }.merge(consent))
118
+ expect(report[:planned]).to eq(1)
119
+ expect(peer.sent).to be_empty
120
+ end
121
+ end
122
+
123
+ it 'documents explicit restore consent and reconnect limitations in runtime help' do
124
+ expect { backup_api.help }.to output(/identity_keys:.*:reject.*:restore.*allow_privacy_changes:.*reconnect/m).to_stdout
125
+ end
126
+
127
+ it 'requires privacy opt-in for location and broker sharing without silently redacting values' do
128
+ profiles = [Meshtastic::DeviceProfile.new(fixed_position: { latitude_i: 1 }),
129
+ Meshtastic::DeviceProfile.new(config: { position: {} }),
130
+ Meshtastic::DeviceProfile.new(module_config: { mqtt: { enabled: true } }),
131
+ Meshtastic::DeviceProfile.new(channel_url: Meshtastic::Admin::Channel.export_url(channels: [
132
+ Meshtastic::Channel.new(role: :PRIMARY, settings: { module_settings: { position_precision: 32 } })
133
+ ]))]
134
+ profiles.each do |profile|
135
+ bytes = profile.to_proto
136
+ expect { backup_api.import(transport_obj: peer.handle, backup: bytes) }.to raise_error(ArgumentError, /allow_privacy_changes/)
137
+ expect(peer.sent).to be_empty
138
+ report = backup_api.import(transport_obj: peer.handle, backup: bytes, dry_run: true, allow_privacy_changes: true)
139
+ expect(report[:warnings].join).to match(/privacy.*location.*MQTT/i)
140
+ expect(report[:planned]).to be_positive
141
+ expect(bytes).to eq(profile.to_proto)
142
+ end
143
+ end
144
+ end
145
+
146
+ describe 'Meshtastic::Admin::Backup telemetry privacy' do
147
+ let(:backup_api) { Meshtastic::Admin::Backup }
148
+ let(:peer) { BackupRadioPeer.new }
149
+
150
+ %i[json device_profile].each do |format|
151
+ context "with #{format} input" do
152
+ let(:telemetry) { {} }
153
+ let(:message) { Meshtastic::ModuleConfig.new(telemetry: telemetry) }
154
+ let(:backup) do
155
+ if format == :device_profile
156
+ Meshtastic::DeviceProfile.new(long_name: 'Portable', module_config: { telemetry: telemetry }).to_proto
157
+ else
158
+ JSON.generate({ 'format' => backup_api::FORMAT, 'version' => 1, 'warning' => '', 'records' => [
159
+ { 'section' => 'owner', 'slot' => nil, 'value' => { 'long_name' => 'Portable' } },
160
+ { 'section' => 'module_config', 'slot' => 'TELEMETRY_CONFIG',
161
+ 'value' => JSON.parse(message.class.encode_json(message, preserve_proto_fieldnames: true)) }
162
+ ] })
163
+ end
164
+ end
165
+ let(:options) { { transport_obj: peer.handle, backup: backup, format: format, timeout: 0.3 } }
166
+
167
+ %i[device_telemetry_enabled environment_measurement_enabled air_quality_enabled power_measurement_enabled health_measurement_enabled].each do |flag|
168
+ it "rejects #{flag} before any requests, including dry runs and edit transactions" do
169
+ telemetry[flag] = true
170
+ [{}, { dry_run: true }, { edit_transaction: true }, { allow_privacy_changes: false }].each do |policy|
171
+ expect { backup_api.import(options.merge(policy)) }.to raise_error(ArgumentError, /allow_privacy_changes/)
172
+ expect(peer.sent).to be_empty
173
+ end
174
+ end
175
+
176
+ it "restores #{flag} unchanged only with explicit consent and warns without values" do
177
+ telemetry.merge!(flag => true, device_update_interval: 123_457)
178
+ original = backup.dup
179
+ report = backup_api.import(options.merge(allow_privacy_changes: true, dry_run: true))
180
+ expect(peer.sent).to be_empty
181
+ expect(report[:warnings].join).to match(/telemetry.*device.*environment.*air quality.*power.*health/i)
182
+ expect(report.inspect).not_to include('123457', 'Portable')
183
+ result = backup_api.import(options.merge(allow_privacy_changes: true, verify: true))
184
+ expect(result).to include(status: :readback_matched, acknowledged: 2, readback_matched: 2)
185
+ expect(peer.sent.find { |sent| sent.payload_variant == :set_module_config }.set_module_config).to eq(message)
186
+ expect(backup).to eq(original)
187
+ end
188
+ end
189
+
190
+ it 'permits disabled broadcasts with nonzero intervals and local display preferences' do
191
+ telemetry.merge!(device_telemetry_enabled: false, environment_measurement_enabled: false,
192
+ air_quality_enabled: false, power_measurement_enabled: false, health_measurement_enabled: false,
193
+ device_update_interval: 60, environment_update_interval: 60, air_quality_interval: 60,
194
+ power_update_interval: 60, health_update_interval: 60, environment_screen_enabled: true,
195
+ power_screen_enabled: true, health_screen_enabled: true, air_quality_screen_enabled: true,
196
+ environment_display_fahrenheit: true)
197
+ result = backup_api.import(options.merge(verify: true))
198
+ expect(result).to include(status: :readback_matched, acknowledged: 2)
199
+ expect(result[:warnings].join).not_to include('Privacy:')
200
+ expect(peer.state[:TELEMETRY_CONFIG]).to eq(message)
201
+ end
202
+
203
+ it 'permits an empty telemetry section without privacy consent' do
204
+ result = backup_api.import(options.merge(dry_run: true))
205
+ expect(result).to include(status: :dry_run, planned: 2)
206
+ expect(result[:warnings].join).not_to include('Privacy:')
207
+ expect(peer.sent).to be_empty
208
+ end
209
+ end
210
+ end
211
+
212
+ it 'documents telemetry consent in runtime help' do
213
+ expect { backup_api.help }.to output(/allow_privacy_changes:.*telemetry.*device.*environment.*air quality.*power.*health/).to_stdout
214
+ end
215
+ end
216
+
217
+ describe 'Meshtastic::Admin::Backup export' do
218
+ let(:backup_api) { Meshtastic::Admin::Backup }
219
+ let(:peer) { BackupRadioPeer.new }
220
+ let(:options) { { transport_obj: peer.handle, config_types: [:DEVICE_CONFIG], module_config_types: [:MQTT_CONFIG], channel_indexes: [0], include_ui: true, timeout: 0.3 } }
221
+
78
222
  it 'exports binary DeviceProfile bytes with fresh values and presence-aware semantic round trips' do
79
223
  peer.state[:owner].is_unmessagable = false
80
224
  peer.state[:channel].settings.psk = "\x01".b
@@ -153,7 +297,7 @@ describe 'Meshtastic::Admin::Backup' do
153
297
  expect(profile.canned_messages).to eq('')
154
298
  expect(peer.sent.map(&:payload_variant)).to eq(%i[get_ringtone_request get_canned_message_module_messages_request])
155
299
  peer.sent.clear
156
- expect(backup_api.import(transport_obj: peer.handle, backup: result[:backup], dry_run: true)[:planned]).to eq(3)
300
+ expect(backup_api.import(transport_obj: peer.handle, backup: result[:backup], dry_run: true, allow_privacy_changes: true)[:planned]).to eq(3)
157
301
  expect(peer.sent).to be_empty
158
302
  [nil, 'bad', { unknown: 1 }].each do |position|
159
303
  expect { backup_api.export(opts.merge(fixed_position: position)) }.to raise_error(ArgumentError)
@@ -183,7 +327,7 @@ describe 'Meshtastic::Admin::Backup' do
183
327
  expect(profile.module_config[field.name]).to eq(peer.state[backup_api::MODULE_TYPES[index]][field.name])
184
328
  end
185
329
  peer.sent.clear
186
- expect(backup_api.import(transport_obj: peer.handle, backup: result[:backup], dry_run: true)[:planned]).to eq(25)
330
+ expect(backup_api.import(transport_obj: peer.handle, backup: result[:backup], dry_run: true, allow_privacy_changes: true)[:planned]).to eq(25)
187
331
  expect(peer.sent).to be_empty
188
332
  peer.state[:device] = Meshtastic::Config.new(network: {})
189
333
  expect { backup_api.export(options.merge(format: :device_profile, include_ui: false, channel_indexes: [])) }.to raise_error(ArgumentError, /slot/)
@@ -219,14 +363,14 @@ describe 'Meshtastic::Admin::Backup import and JSON export' do
219
363
  Meshtastic::Config.descriptor.to_a.take(8).each { |field| config[field.name] = field.subtype.msgclass.new }
220
364
  modules = Meshtastic::LocalModuleConfig.new(version: 24)
221
365
  Meshtastic::ModuleConfig.descriptor.each { |field| modules[field.name] = field.subtype.msgclass.new }
222
- config.security = Meshtastic::Config::SecurityConfig.new(private_key: "\x00\xff".b, admin_key: ['synthetic-key'], serial_enabled: false)
366
+ config.security = Meshtastic::Config::SecurityConfig.new(private_key: "\x00\xff".b * 16, admin_key: ['k' * 32], serial_enabled: false)
223
367
  config.lora = Meshtastic::Config::LoRaConfig.new(region: :US, tx_power: -1, frequency_offset: 1.25, ignore_incoming: [1, 0xffffffff])
224
368
  modules.mqtt = Meshtastic::ModuleConfig::MQTTConfig.new(enabled: false, password: 'synthetic-password')
225
369
  channels = Meshtastic::ChannelSet.new(settings: [{ name: 'test', psk: "\x01".b }], lora_config: config.lora)
226
370
  profile = Meshtastic::DeviceProfile.new(long_name: 'Profile', short_name: '', is_licensed: false, is_unmessagable: false,
227
371
  config: config, module_config: modules, channel_url: "https://meshtastic.org/e/##{Base64.urlsafe_encode64(channels.to_proto, padding: false)}",
228
372
  fixed_position: { latitude_i: 0, longitude_i: 0, altitude: 0 }, ringtone: '', canned_messages: '')
229
- result = backup_api.import(transport_obj: peer.handle, backup: profile.to_proto, format: :device_profile, verify: true, timeout: 0.3)
373
+ result = backup_api.import(transport_obj: peer.handle, backup: profile.to_proto, format: :device_profile, verify: true, identity_keys: :restore, allow_privacy_changes: true, timeout: 0.3)
230
374
  expect(result).to include(status: :readback_incomplete, planned: 30, acknowledged: 30, readback_matched: 29)
231
375
  expect(result[:records].find { |record| record[:section] == 'fixed_position' }).to include(readback: :unsupported)
232
376
  expect(peer.state[:owner]).to eq(Meshtastic::User.new(long_name: 'Profile', short_name: '', is_licensed: false, is_unmessagable: false))
@@ -344,7 +488,7 @@ describe 'Meshtastic::Admin::Backup missing write ACK recovery' do
344
488
  let(:backup_api) { Meshtastic::Admin::Backup }
345
489
  let(:peer) { BackupRadioPeer.new }
346
490
  let(:profile) { Meshtastic::DeviceProfile.new(module_config: { mqtt: { enabled: true, password: 'synthetic-secret' } }, config: { lora: { region: :US } }) }
347
- let(:options) { { transport_obj: peer.handle, backup: profile.to_proto, edit_transaction: true, timeout: 0.15 } }
491
+ let(:options) { { transport_obj: peer.handle, backup: profile.to_proto, edit_transaction: true, allow_privacy_changes: true, timeout: 0.15 } }
348
492
 
349
493
  before { peer.drop_ack_on = :set_module_config }
350
494
 
@@ -517,13 +661,13 @@ describe 'Meshtastic::Admin::Backup DeviceProfile validation' do
517
661
  it 'keeps ordinary profile writes in source plan order before disruptive sections' do
518
662
  modules = Meshtastic::ModuleConfig.descriptor.to_h { |field| [field.name.to_sym, {}] }
519
663
  profile = Meshtastic::DeviceProfile.new(long_name: 'order', ringtone: '', canned_messages: '', config: { device: {}, position: {}, power: {}, display: {}, lora: {}, security: {} }, module_config: modules)
520
- plan = backup_api.import(transport_obj: peer.handle, backup: profile.to_proto, dry_run: true)[:plan]
664
+ plan = backup_api.import(transport_obj: peer.handle, backup: profile.to_proto, dry_run: true, allow_privacy_changes: true)[:plan]
521
665
  expect(plan.map { |entry| entry[:slot] || entry[:section] }).to eq(%w[owner ringtone canned_messages DEVICE_CONFIG POSITION_CONFIG POWER_CONFIG DISPLAY_CONFIG] + backup_api::MODULE_TYPES.map(&:to_s) + %w[LORA_CONFIG SECURITY_CONFIG])
522
666
  expect(peer.sent).to be_empty
523
667
  end
524
668
 
525
669
  it 'auto-detects binary content and cfg paths while retaining explicit and automatic JSON input' do
526
- profile = Meshtastic::DeviceProfile.new(long_name: 'x' * 123, is_unmessagable: false)
670
+ profile = Meshtastic::DeviceProfile.new(long_name: 'x' * 24, is_unmessagable: false)
527
671
  Dir.mktmpdir do |directory|
528
672
  ['profile.cfg', 'profile.bin'].each do |name|
529
673
  path = File.join(directory, name)
@@ -683,7 +827,7 @@ describe 'Meshtastic::Admin::Backup validation and readback' do
683
827
  end
684
828
  document = backup_api.export(options.merge(config_types: backup_api::CONFIG_TYPES, module_config_types: backup_api::MODULE_TYPES, include_owner: false, include_ui: false, channel_indexes: []))[:backup]
685
829
  peer.sent.clear
686
- result = backup_api.import(transport_obj: peer.handle, backup: document, timeout: 0.3)
830
+ result = backup_api.import(transport_obj: peer.handle, backup: document, allow_privacy_changes: true, timeout: 0.3)
687
831
  expect(result[:acknowledged]).to eq(configs.length + modules.length)
688
832
  restored = peer.sent.map { |message| message.public_send(message.payload_variant) }
689
833
  expect(restored).to match_array(configs + modules)
@@ -9,7 +9,7 @@ RSpec.describe Meshtastic::Admin::Config do
9
9
  end
10
10
  end
11
11
 
12
- describe Meshtastic::Admin::Config do
12
+ module AdminConfigSpecHelpers
13
13
  def fake_serial_obj
14
14
  written = +''.b
15
15
  serial_conn = Object.new
@@ -28,6 +28,10 @@ describe Meshtastic::Admin::Config do
28
28
  body = frame.byteslice(4, (frame.getbyte(2) << 8) + frame.getbyte(3))
29
29
  Meshtastic::AdminMessage.decode(Meshtastic::ToRadio.decode(body).packet.decoded.payload)
30
30
  end
31
+ end
32
+
33
+ describe Meshtastic::Admin::Config do
34
+ include AdminConfigSpecHelpers
31
35
 
32
36
  it 'rejects legacy connection keys rather than silently filtering them' do
33
37
  %i[serial_obj bluetooth_obj tcp_obj mqtt_obj].each do |key|
@@ -123,6 +127,71 @@ describe Meshtastic::Admin::Config do
123
127
  expect(message.get_config_request).to eq(config_type)
124
128
  end
125
129
  end
130
+ end
131
+
132
+ describe Meshtastic::Admin::Config, 'read-modify-readback helpers' do
133
+ include AdminConfigSpecHelpers
134
+
135
+ it 'updates signature policy with fresh correlated readback while preserving other security fields' do
136
+ current = Meshtastic::Config.new(security: { serial_enabled: true, public_key: 'k' * 32 })
137
+ handle = fake_serial_obj.merge(from_radio_queue: Queue.new, messages: [])
138
+ handle[:serial_conn].define_singleton_method(:write) do |bytes|
139
+ packet = Meshtastic::ToRadio.decode(bytes.byteslice(4..)).packet
140
+ message = Meshtastic::AdminMessage.decode(packet.decoded.payload)
141
+ handle[:messages] << message
142
+ if message.payload_variant == :get_config_request
143
+ payload = Meshtastic::AdminMessage.new(get_config_response: current).to_proto
144
+ data = Meshtastic::Data.new(portnum: :ADMIN_APP, request_id: packet.id, payload: payload)
145
+ else
146
+ current = Meshtastic::Config.decode(message.set_config.to_proto)
147
+ data = Meshtastic::Data.new(portnum: :ROUTING_APP, request_id: packet.id)
148
+ end
149
+ handle[:from_radio_queue] << Meshtastic::FromRadio.new(packet: Meshtastic::MeshPacket.new(from: packet.to, decoded: data))
150
+ bytes.bytesize
151
+ end
152
+ result = described_class.set_packet_signature_policy(transport_obj: handle, policy: :PACKET_SIGNATURE_POLICY_STRICT)
153
+ expect(current.security).to have_attributes(serial_enabled: true, public_key: 'k' * 32, packet_signature_policy: :PACKET_SIGNATURE_POLICY_STRICT)
154
+ expect(handle[:messages].map(&:payload_variant)).to eq(%i[get_config_request set_config get_config_request])
155
+ expect(result).to include(matched: true, reboot_requested: false)
156
+ expect(result[:acknowledgment][:delivery]).to eq(:acknowledged)
157
+ expect(result[:readback][:delivery]).to eq(:readback)
158
+ end
159
+
160
+ it 'sets local receive-only without resetting LoRa fields or issuing a reboot and reports mismatched readback' do
161
+ [true, false].each do |apply|
162
+ current = Meshtastic::Config.new(lora: { region: :US, hop_limit: 3, tx_enabled: true })
163
+ handle = fake_serial_obj.merge(from_radio_queue: Queue.new, messages: [])
164
+ handle[:serial_conn].define_singleton_method(:write) do |bytes|
165
+ packet = Meshtastic::ToRadio.decode(bytes.byteslice(4..)).packet
166
+ message = Meshtastic::AdminMessage.decode(packet.decoded.payload)
167
+ handle[:messages] << message
168
+ if message.payload_variant == :get_config_request
169
+ data = Meshtastic::Data.new(portnum: :ADMIN_APP, request_id: packet.id,
170
+ payload: Meshtastic::AdminMessage.new(get_config_response: current).to_proto)
171
+ else
172
+ current = message.set_config if apply
173
+ data = Meshtastic::Data.new(portnum: :ROUTING_APP, request_id: packet.id)
174
+ end
175
+ handle[:from_radio_queue] << Meshtastic::FromRadio.new(packet: Meshtastic::MeshPacket.new(from: packet.to, decoded: data))
176
+ bytes.bytesize
177
+ end
178
+ result = described_class.set_receive_only(transport_obj: handle, enabled: true)
179
+ expect(result).to include(matched: apply, reboot_requested: false)
180
+ expect(handle[:messages][1].set_config.lora).to have_attributes(region: :US, hop_limit: 3, tx_enabled: false)
181
+ expect(handle[:messages].map(&:payload_variant)).to eq(%i[get_config_request set_config get_config_request])
182
+ end
183
+ end
184
+
185
+ it 'rejects invalid policy and receive-only settings before any transmission' do
186
+ handle = fake_serial_obj
187
+ [99, nil, true, 'PACKET_SIGNATURE_POLICY_STRICT', :UNKNOWN].each do |policy|
188
+ expect { described_class.set_packet_signature_policy(transport_obj: handle, policy: policy) }.to raise_error(ArgumentError)
189
+ end
190
+ expect { described_class.set_receive_only(transport_obj: handle, enabled: 'true') }.to raise_error(ArgumentError)
191
+ expect { described_class.set_receive_only(transport_obj: handle, enabled: true, to: '!aabbccdd') }.to raise_error(ArgumentError, /local/)
192
+ expect { described_class.set_receive_only(transport_obj: MQTTClient.new, enabled: true) }.to raise_error(ArgumentError, /local/)
193
+ expect(handle[:written]).to be_empty
194
+ end
126
195
 
127
196
  it 'prints usage without raising' do
128
197
  expect { described_class.help }.to output(/USAGE/).to_stdout
@@ -0,0 +1,121 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'spec_helper'
4
+
5
+ RSpec.describe Meshtastic::Admin::Firmware, 'v2.8.1 migration (no hardware)' do
6
+ let(:version) { '2.8.1.8e6a88d' }
7
+ let(:public_key) { 'k'.b * 32 }
8
+
9
+ # Hardware writes are mocked; ToRadio, AdminMessage and FromRadio codecs and
10
+ # Admin source/request correlation are production code, not mocked requests.
11
+ def radio(kind, version:, key:, decoys: false, fresh: true)
12
+ queue = Queue.new
13
+ requests = []
14
+ writer = Object.new
15
+ writer.define_singleton_method(:write) do |bytes|
16
+ packet = Meshtastic::ToRadio.decode(kind == :bluetooth ? bytes : bytes.byteslice(4..)).packet
17
+ request = Meshtastic::AdminMessage.decode(packet.decoded.payload)
18
+ requests << [packet.to, request.payload_variant]
19
+ response = if request.get_owner_request
20
+ Meshtastic::AdminMessage.new(get_owner_response: Meshtastic::User.new(public_key: key))
21
+ else
22
+ Meshtastic::AdminMessage.new(get_device_metadata_response: Meshtastic::DeviceMetadata.new(firmware_version: version))
23
+ end
24
+ sources = decoys ? [[999, packet.id], [456, packet.id - 1]] : []
25
+ sources << [456, packet.id] if fresh
26
+ sources.each do |from, id|
27
+ frame = Meshtastic::FromRadio.new(packet: Meshtastic::MeshPacket.new(from: from, decoded: Meshtastic::Data.new(portnum: :ADMIN_APP, request_id: id, payload: response.to_proto)))
28
+ queue << Meshtastic::FromRadio.decode(frame.to_proto)
29
+ end
30
+ bytes.bytesize
31
+ end
32
+ writer.define_singleton_method(:flush) { true }
33
+ handle = { my_node_num: 456, from_radio_queue: queue, tx_mutex: Mutex.new, metadata: { firmware_version: '2.8.1.8e6a88d' } }
34
+ handle[kind == :bluetooth ? :bluetooth_conn : :serial_conn] = writer
35
+ handle[:tcp_socket] = writer if kind == :tcp
36
+ transport = { serial: Meshtastic::Serial, bluetooth: Meshtastic::Bluetooth, tcp: Meshtastic::TCP }.fetch(kind)
37
+ allow(transport).to receive(:wait_for_config).and_return(handle)
38
+ allow(transport).to receive(:disconnect)
39
+ [handle, requests]
40
+ end
41
+
42
+ it 'rejects a reconnect callback returning a different transport before Admin traffic' do
43
+ handle, requests = radio(:serial, version: version, key: public_key)
44
+ allow(Meshtastic::Bluetooth).to receive(:wait_for_config).and_return(handle)
45
+ expect(Meshtastic::Serial).to receive(:disconnect).with(serial_obj: handle)
46
+ expect(Meshtastic::Bluetooth).not_to receive(:disconnect)
47
+ expect do
48
+ described_class.verify_reboot(transport: :bluetooth, reconnect: ->(_) { handle }, expected_version: version, reboot_delay: 0, timeout: 0.1)
49
+ end.to raise_error(ArgumentError, /transport/)
50
+ expect(requests).to be_empty
51
+ end
52
+
53
+ it 'validates migration identity and explicit endpoint settings before invoking any loader' do
54
+ base = { transport: :serial, connection: { block_dev: '/dev/ttyACM0' }, expected_version: version }
55
+ invalid = [{ expected_public_key: 'short' }, { expected_public_key: 'a' * 64 }, { expected_public_key: "\x00" * 32 },
56
+ { previous_node: 123 }, { expected_node: 0xffffffff }, { previous_node: '!0000007b', expected_public_key: public_key },
57
+ { to: '!000001c8' }, { connection: {} }, { transport: :mqtt }]
58
+ expect(described_class::BLE).not_to receive(:install)
59
+ invalid.each do |options|
60
+ expect { described_class.install(protocol: :unified_ble, bytes: 'abc', verify: base.merge(options)) }.to raise_error(ArgumentError)
61
+ end
62
+ end
63
+
64
+ it 'documents the public continuity options in actual runtime help' do
65
+ expect { described_class.help }.to output(/expected_public_key:.*previous_node:/m).to_stdout
66
+ end
67
+
68
+ %i[serial bluetooth tcp].each do |kind|
69
+ %w[2.8.1.wrongsuffix 2.8.1 2.8.0.8e6a88d 2.7.23.b246bcd].each do |wrong|
70
+ it "rejects #{wrong} despite exact cached metadata over #{kind}" do
71
+ handle, = radio(kind, version: wrong, key: public_key)
72
+ expect do
73
+ described_class.verify_reboot(transport: kind, reconnect: ->(_) { handle }, expected_version: version,
74
+ expected_public_key: public_key, previous_node: 123, reboot_delay: 0, timeout: 1)
75
+ end.to raise_error(IOError, /version mismatch/)
76
+ end
77
+ end
78
+
79
+ it "does not accept an arbitrary new node with another key over #{kind}" do
80
+ handle, requests = radio(kind, version: version, key: 'x' * 32)
81
+ expect do
82
+ described_class.verify_reboot(transport: kind, reconnect: ->(_) { handle }, expected_version: version,
83
+ expected_public_key: public_key, previous_node: 123, reboot_delay: 0, timeout: 1)
84
+ end.to raise_error(IOError, 'Post-reboot public-key identity mismatch')
85
+ expect(requests).to eq([[456, :get_owner_request]])
86
+ end
87
+
88
+ it "does not relax an explicitly strict numeric expectation over #{kind}" do
89
+ handle, requests = radio(kind, version: version, key: public_key)
90
+ expect do
91
+ described_class.verify_reboot(transport: kind, reconnect: ->(_) { handle }, expected_version: version,
92
+ expected_node: 123, expected_public_key: public_key, reboot_delay: 0, timeout: 1)
93
+ end.to raise_error(IOError, /node identity mismatch/)
94
+ expect(requests).to be_empty
95
+ end
96
+
97
+ [false, true].each do |owner|
98
+ it "rejects stale or wrong-source #{owner ? 'owner' : 'metadata'} and retains unrelated traffic over #{kind}" do
99
+ handle, = radio(kind, version: version, key: public_key, decoys: true, fresh: false)
100
+ identity = owner ? { expected_public_key: public_key } : {}
101
+ expect do
102
+ described_class.verify_reboot({ transport: kind, reconnect: ->(_) { handle }, expected_version: version,
103
+ reboot_delay: 0, timeout: 0.05 }.merge(identity))
104
+ end.to raise_error(Timeout::Error)
105
+ expect(handle[:from_radio_queue].size).to eq(2)
106
+ end
107
+ end
108
+
109
+ it "allows an explicit key-continuous node migration over #{kind} without exposing keys" do
110
+ handle, requests = radio(kind, version: version, key: public_key, decoys: true)
111
+ result = described_class.verify_reboot(transport: kind, reconnect: ->(_) { handle }, expected_version: version,
112
+ expected_public_key: public_key, previous_node: 123, reboot_delay: 0, timeout: 1)
113
+ expect(result).to include(status: :boot_verified, node_num: 456, previous_node_num: 123,
114
+ node_id: '!000001c8', previous_node_id: '!0000007b', node_changed: true,
115
+ identity: :public_key_continuity)
116
+ expect(requests).to eq([[456, :get_owner_request], [456, :get_device_metadata_request]])
117
+ expect(result.inspect).not_to include(public_key)
118
+ expect(handle[:from_radio_queue].size).to eq(4)
119
+ end
120
+ end
121
+ end
@@ -134,7 +134,7 @@ RSpec.describe Meshtastic::Admin::Firmware do
134
134
  handle = { serial_conn: Object.new, my_node_num: 123 }
135
135
  expect(Meshtastic::Serial).to receive(:wait_for_config).with(serial_obj: handle, timeout: 1).and_return(handle)
136
136
  expect(Meshtastic::Serial).to receive(:disconnect).with(serial_obj: handle)
137
- expect(Meshtastic::Admin).to receive(:request).with(transport_obj: handle, get_device_metadata_request: true, timeout: 1)
137
+ expect(Meshtastic::Admin).to receive(:request).with(transport_obj: handle, to: 123, get_device_metadata_request: true, timeout: 1)
138
138
  .and_return(value: Meshtastic::DeviceMetadata.new(firmware_version: 'expected'))
139
139
  result = described_class.verify_reboot(transport: :serial, reconnect: ->(_options) { handle },
140
140
  expected_version: 'expected', reboot_delay: 0, timeout: 1)
@@ -0,0 +1,44 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'spec_helper'
4
+
5
+ RSpec.describe Meshtastic::Admin::Firmware, 'installer target selection (no hardware)' do
6
+ it 'rejects unimplemented chips before opening a serial programmer' do
7
+ expect(UART).not_to receive(:open)
8
+ %i[esp32c6 rp2350 stm32 nrf52840].each do |chip|
9
+ expect do
10
+ described_class.install(protocol: :esp_rom, chip: chip, bytes: 'image', port: '/dev/ttyACM0', offset: 0x10000, flash_size: 4_194_304)
11
+ end.to raise_error(ArgumentError, /chip must/)
12
+ end
13
+ end
14
+
15
+ it 'never guesses a Secure Nordic or unsupported board loader from an artifact' do
16
+ expect(Socket).not_to receive(:tcp)
17
+ expect(UART).not_to receive(:open)
18
+ %i[nordic_secure_dfu rp2350 stm32 mqtt].each do |protocol|
19
+ expect { described_class.install(protocol: protocol, bytes: 'image') }.to raise_error(NotImplementedError)
20
+ end
21
+ end
22
+
23
+ it 'does not pretend that full erase is implemented' do
24
+ expect(UART).not_to receive(:open)
25
+ expect(Socket).not_to receive(:tcp)
26
+ %i[esp_rom unified_wifi unified_ble nordic_dfu uf2 swd].each do |protocol|
27
+ expect { described_class.install(protocol: protocol, bytes: 'image', full_erase: true) }.to raise_error(ArgumentError, /[Uu]nsupported|[Uu]nknown/)
28
+ end
29
+ end
30
+
31
+ it 'rejects unsupported RP2350 UF2 families before opening a destination' do
32
+ expect(File).not_to receive(:open)
33
+ expect do
34
+ described_class.install(protocol: :uf2, bytes: 'image', family_id: 0xe48bff59, mount: '/selected/volume', board_id: 'RP2350')
35
+ end.to raise_error(ArgumentError, /Unsupported UF2 family/)
36
+ end
37
+
38
+ it 'requires the exact supported SWD chip before invoking any programmer' do
39
+ expect(described_class::Hex).not_to receive(:program)
40
+ expect do
41
+ described_class::Hex.validate(bytes: ':00000001FF', expected_chip: :stm32)
42
+ end.to raise_error(ArgumentError, /nrf52840/)
43
+ end
44
+ end
@@ -0,0 +1,89 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'spec_helper'
4
+
5
+ describe 'Admin semantic validation' do
6
+ it 'rejects owner names beyond the pinned firmware UTF-8 byte limit without truncating' do
7
+ name = 'é' * 13
8
+ user = Meshtastic::User.new(long_name: name)
9
+ expect { Meshtastic::Admin.encode(set_owner: user) }.to raise_error(ArgumentError, /24/)
10
+ expect(user.long_name).to eq(name)
11
+ expect(Meshtastic::Admin.encode(set_owner: Meshtastic::User.new(long_name: 'é' * 12)).set_owner.long_name.bytesize).to eq(24)
12
+ end
13
+
14
+ it 'rejects names firmware cannot represent, while preserving omitted names' do
15
+ [{ long_name: "bad\0name" }, { long_name: " \t" }, { short_name: 'ééé' }].each do |fields|
16
+ expect { Meshtastic::Admin.encode(set_owner: Meshtastic::User.new(fields)) }.to raise_error(ArgumentError)
17
+ end
18
+ expect { Meshtastic::Admin.encode(set_owner: Meshtastic::User.new) }.not_to raise_error
19
+ end
20
+
21
+ it 'validates ham nanopb fields and the composed owner name before encoding' do
22
+ [{ call_sign: '' }, { call_sign: ' ' }, { call_sign: 'ABCDEFGH' },
23
+ { call_sign: 'N0CALL', long_name: 'é' * 8 }, { call_sign: 'N0CALL', short_name: 'ABCDE' }].each do |fields|
24
+ expect { Meshtastic::Admin.encode(set_ham_mode: Meshtastic::HamParameters.new(fields)) }.to raise_error(ArgumentError)
25
+ end
26
+ expect { Meshtastic::Admin.encode(set_ham_mode: Meshtastic::HamParameters.new(call_sign: 'N0CALL', long_name: 'é' * 7)) }.not_to raise_error
27
+ end
28
+
29
+ it 'rejects unknown enum integers recursively and accepts known numeric enum values' do
30
+ [Meshtastic::AdminMessage.new(get_config_request: 999),
31
+ Meshtastic::AdminMessage.new(set_config: { lora: { region: 999 } }),
32
+ Meshtastic::AdminMessage.new(set_module_config: { serial: { mode: 999 } })].each do |message|
33
+ expect { Meshtastic::Admin.encode(message: message) }.to raise_error(ArgumentError, /enum/)
34
+ end
35
+ expect { Meshtastic::Admin.encode(get_config_request: 0) }.not_to raise_error
36
+ end
37
+
38
+ it 'requires a selected writable config section but allows an explicitly selected default section' do
39
+ [Meshtastic::AdminMessage.new(set_config: Meshtastic::Config.new),
40
+ Meshtastic::AdminMessage.new(set_module_config: Meshtastic::ModuleConfig.new),
41
+ Meshtastic::AdminMessage.new(set_config: { sessionkey: {} }),
42
+ Meshtastic::AdminMessage.new(set_config: { device_ui: {} })].each do |message|
43
+ expect { Meshtastic::Admin.encode(message: message) }.to raise_error(ArgumentError, /section|sessionkey|device_ui/)
44
+ end
45
+ expect { Meshtastic::Admin.encode(set_config: Meshtastic::Config.new(device: {})) }.not_to raise_error
46
+ end
47
+
48
+ it 'applies channel PSK, name, index and role validation to raw Admin writes' do
49
+ [{ index: 8 }, { index: -1 }, { role: 99 }, { settings: { psk: 'bad' } },
50
+ { settings: { name: 'é' * 6 } }, { settings: { name: "x\0y" } }].each do |fields|
51
+ expect { Meshtastic::Admin.encode(set_channel: Meshtastic::Channel.new(fields)) }.to raise_error(ArgumentError)
52
+ end
53
+ [0, 1, 16, 32].each do |length|
54
+ channel = Meshtastic::Channel.new(index: 7, role: :PRIMARY, settings: { name: 'é' * 5, psk: 'x' * length })
55
+ expect(Meshtastic::Admin.encode(set_channel: channel).set_channel).to eq(channel)
56
+ end
57
+ end
58
+
59
+ it 'preflights every URL section and rejects invalid offline settings before any write' do
60
+ expect(Meshtastic::Admin).not_to receive(:send)
61
+ expect { Meshtastic::Admin::Channel.build_settings(name: "x\0y") }.to raise_error(ArgumentError)
62
+ set = Meshtastic::ChannelSet.new(settings: [{ psk: 'x' }], lora_config: { region: 999 })
63
+ url = "https://meshtastic.org/e/##{Base64.urlsafe_encode64(set.to_proto, padding: false)}"
64
+ expect { Meshtastic::Admin::Channel.apply_url(url: url, transport_obj: { serial_conn: Object.new }) }.to raise_error(ArgumentError)
65
+ end
66
+
67
+ it 'rejects malformed security key sizes rather than silently triggering firmware identity preservation' do
68
+ [{ private_key: 'bad' }, { public_key: 'bad' }, { admin_key: ['bad'] }].each do |fields|
69
+ expect { Meshtastic::Admin.encode(set_config: Meshtastic::Config.new(security: fields)) }.to raise_error(ArgumentError, /key/)
70
+ end
71
+ expect { Meshtastic::Admin.encode(set_config: Meshtastic::Config.new(security: { private_key: 'k' * 32 })) }.not_to raise_error
72
+ end
73
+
74
+ it 'rejects invalid mutations before delivery or remote session acquisition across public setters' do
75
+ handle = { serial_conn: Object.new, my_node_num: 0xaabbccdd, from_radio_queue: Queue.new }
76
+ transport = { transport_obj: handle, to: '!aabbccee' }
77
+ expect(Meshtastic).not_to receive(:deliver_data)
78
+ [[:set_owner, { long_name: 'x' * 25 }], [:set_ham_mode, { call_sign: '' }],
79
+ [:set_channel, { channel_settings: Meshtastic::Channel.new(index: 8) }],
80
+ [:set_config, { config: Meshtastic::Config.new }],
81
+ [:set_module_config, { module_config: Meshtastic::ModuleConfig.new }]].each do |method, payload|
82
+ expect { Meshtastic::Admin.public_send(method, transport.merge(payload)) }.to raise_error(ArgumentError)
83
+ end
84
+ expect { Meshtastic::Admin::Config.set_lora(transport.merge(lora: { region: 999 })) }.to raise_error(ArgumentError)
85
+ expect { Meshtastic::ModuleConfig.set(transport.merge(module_config: Meshtastic::ModuleConfig.new)) }.to raise_error(ArgumentError)
86
+ expect { Meshtastic::Admin::Channel.set(transport.merge(settings: { psk: 'bad' })) }.to raise_error(ArgumentError)
87
+ expect(handle).not_to have_key(:admin_sessions)
88
+ end
89
+ end