meshtastic 0.0.183 → 0.0.185

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 (50) hide show
  1. checksums.yaml +4 -4
  2. data/.rubocop_todo.yml +1 -0
  3. data/Gemfile +2 -1
  4. data/documentation/README.md +6 -0
  5. data/documentation/admin-backup.md +369 -0
  6. data/documentation/admin.md +4 -0
  7. data/documentation/forwarder.md +149 -0
  8. data/documentation/mesh-interface.md +157 -1
  9. data/documentation/payload-formats.md +187 -0
  10. data/documentation/reticulum.md +89 -0
  11. data/lib/meshtastic/admin/backup.rb +560 -0
  12. data/lib/meshtastic/admin.rb +2 -0
  13. data/lib/meshtastic/atak.rb +98 -13
  14. data/lib/meshtastic/atak_pb.rb +3 -1
  15. data/lib/meshtastic/config_pb.rb +1 -1
  16. data/lib/meshtastic/field_metadata_pb.rb +1 -1
  17. data/lib/meshtastic/forwarder.rb +190 -0
  18. data/lib/meshtastic/forwarder_pb.rb +77 -0
  19. data/lib/meshtastic/mesh_beacon_pb.rb +1 -1
  20. data/lib/meshtastic/mesh_interface.rb +115 -65
  21. data/lib/meshtastic/mesh_pb.rb +2 -1
  22. data/lib/meshtastic/module_config_pb.rb +2 -1
  23. data/lib/meshtastic/mqtt.rb +4 -34
  24. data/lib/meshtastic/payload_compression.rb +90 -0
  25. data/lib/meshtastic/payload_formats.rb +248 -0
  26. data/lib/meshtastic/reticulum.rb +71 -0
  27. data/lib/meshtastic/serial.rb +1 -19
  28. data/lib/meshtastic/telemetry_pb.rb +2 -2
  29. data/lib/meshtastic/unishox2.rb +467 -0
  30. data/lib/meshtastic/version.rb +1 -1
  31. data/lib/meshtastic.rb +4 -0
  32. data/meshtastic.gemspec +2 -0
  33. data/spec/lib/meshtastic/admin/backup_spec.rb +704 -0
  34. data/spec/lib/meshtastic/atak_spec.rb +50 -0
  35. data/spec/lib/meshtastic/bluetooth_spec.rb +14 -0
  36. data/spec/lib/meshtastic/forwarder_pb_spec.rb +11 -0
  37. data/spec/lib/meshtastic/forwarder_spec.rb +97 -0
  38. data/spec/lib/meshtastic/mesh_interface_spec.rb +169 -0
  39. data/spec/lib/meshtastic/mqtt_spec.rb +56 -0
  40. data/spec/lib/meshtastic/payload_compression_spec.rb +43 -0
  41. data/spec/lib/meshtastic/payload_formats_spec.rb +160 -0
  42. data/spec/lib/meshtastic/reticulum_spec.rb +91 -0
  43. data/spec/lib/meshtastic/serial_spec.rb +46 -0
  44. data/spec/lib/meshtastic/tcp_spec.rb +15 -0
  45. data/spec/lib/meshtastic/unishox2_spec.rb +34 -0
  46. data/spec/support/payload_fixtures.rb +124 -0
  47. data/spec/support/reticulum_fixtures.json +12 -0
  48. data/spec/support/tak_codec_fixtures.rb +4 -0
  49. data/spec/support/unishox_fixtures.rb +4 -0
  50. metadata +39 -3
@@ -0,0 +1,560 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'json'
4
+ require 'base64'
5
+ require 'uri'
6
+ require 'stringio'
7
+ require 'meshtastic/clientonly_pb'
8
+ require 'meshtastic/admin/channel'
9
+ require 'meshtastic/admin'
10
+
11
+ module Meshtastic
12
+ module Admin
13
+ # Host-side snapshots, not firmware filesystem backup commands.
14
+ module Backup
15
+ FORMAT = 'meshtastic-admin-backup'
16
+ WARNING = 'Contains secrets returned by firmware. Firmware may redact secrets; absent/default values are not proof of completeness. Owner identity and hardware metadata are excluded. ACKs do not prove persistence.'
17
+ CONFIG_TYPES = %i[DEVICE_CONFIG POSITION_CONFIG POWER_CONFIG NETWORK_CONFIG DISPLAY_CONFIG LORA_CONFIG BLUETOOTH_CONFIG SECURITY_CONFIG].freeze
18
+ MODULE_TYPES = %i[MQTT_CONFIG SERIAL_CONFIG EXTNOTIF_CONFIG STOREFORWARD_CONFIG RANGETEST_CONFIG TELEMETRY_CONFIG CANNEDMSG_CONFIG AUDIO_CONFIG REMOTEHARDWARE_CONFIG NEIGHBORINFO_CONFIG AMBIENTLIGHTING_CONFIG DETECTIONSENSOR_CONFIG PAXCOUNTER_CONFIG STATUSMESSAGE_CONFIG TRAFFICMANAGEMENT_CONFIG TAK_CONFIG MESHBEACON_CONFIG].freeze
19
+ OWNER_FIELDS = %w[long_name short_name is_licensed].freeze
20
+
21
+ # JSON 2.x otherwise silently keeps the last duplicate key.
22
+ class StrictObject < Hash
23
+ def []=(key, value)
24
+ raise ArgumentError, 'duplicate JSON key' if key?(key)
25
+
26
+ super
27
+ end
28
+ end
29
+ private_constant :StrictObject
30
+
31
+ public_class_method def self.export(opts = {})
32
+ validate_options(options: opts, operation: :export)
33
+ connection = connection_options(opts)
34
+ return export_profile(opts.merge(connection: connection)) if opts.fetch(:format, :json) == :device_profile
35
+
36
+ records = []
37
+ selection(opts).each do |section, slot|
38
+ value = Admin.request(connection.merge(read_command(section: section, slot: slot)))[:value]
39
+ value = Meshtastic::User.new(long_name: value.long_name, short_name: value.short_name, is_licensed: value.is_licensed) if section == 'owner'
40
+ json = JSON.parse(value.class.encode_json(value, emit_defaults: true, preserve_proto_fieldnames: true))
41
+ json.select! { |key, _| OWNER_FIELDS.include?(key) } if section == 'owner'
42
+ records << { 'section' => section, 'slot' => slot, 'value' => json }
43
+ end
44
+ document = { 'format' => FORMAT, 'version' => 1, 'warning' => WARNING, 'records' => records }
45
+ validate_document(document: document)
46
+ if opts[:path]
47
+ File.open(opts[:path], File::WRONLY | File::CREAT | File::EXCL | File::NOFOLLOW, 0o600) do |file|
48
+ file.chmod(0o600)
49
+ file.write(JSON.pretty_generate(document))
50
+ file.flush
51
+ file.fsync
52
+ end
53
+ end
54
+ { status: :exported, count: records.length, backup: document, warnings: [WARNING] }
55
+ end
56
+
57
+ private_class_method def self.export_profile(opts = {})
58
+ selected = selection(opts)
59
+ raise ArgumentError, 'DeviceProfile cannot represent UI configuration' if opts.fetch(:include_ui, false)
60
+
61
+ indexes = opts.fetch(:channel_indexes, (0..7).to_a)
62
+ raise ArgumentError, 'DeviceProfile channels must be an ordered contiguous prefix starting at zero' unless indexes == (0...indexes.length).to_a
63
+ raise ArgumentError, 'DeviceProfile selection is empty' if selected.empty? && !opts.key?(:fixed_position)
64
+
65
+ profile = Meshtastic::DeviceProfile.new
66
+ profile.fixed_position = profile_position(value: opts[:fixed_position]) if opts.key?(:fixed_position)
67
+ channels = []
68
+ selected.each do |section, slot|
69
+ value = Admin.request(opts[:connection].merge(read_command(section: section, slot: slot)))[:value]
70
+ case section
71
+ when 'owner'
72
+ OWNER_FIELDS.each { |name| profile[name] = value[name] }
73
+ profile.is_unmessagable = value.is_unmessagable if value.has_is_unmessagable?
74
+ when 'config', 'module_config'
75
+ klass, types, container = section == 'config' ? [Meshtastic::Config, CONFIG_TYPES, Meshtastic::LocalConfig] : [Meshtastic::ModuleConfig, MODULE_TYPES, Meshtastic::LocalModuleConfig]
76
+ field = klass.descriptor.to_a[types.index(slot.to_sym)].name
77
+ raise ArgumentError, 'protobuf section does not match slot' unless value.payload_variant.to_s == field
78
+
79
+ profile[section] ||= container.new
80
+ profile[section][field] = value[field]
81
+ when 'channel'
82
+ raise ArgumentError, 'channel index or role cannot round-trip in DeviceProfile' unless value.index == slot && value.role == (slot.zero? ? :PRIMARY : :SECONDARY) && value.settings
83
+
84
+ channels << value
85
+ when 'ringtone', 'canned_messages'
86
+ profile[section] = value
87
+ end
88
+ end
89
+ profile.channel_url = Admin::Channel.export_url(channels: channels, lora_config: profile.config&.lora) unless channels.empty?
90
+ bytes = profile.to_proto.b
91
+ count = profile_plan(bytes: bytes).length
92
+ if opts[:path]
93
+ File.open(opts[:path], File::WRONLY | File::CREAT | File::EXCL | File::NOFOLLOW, 0o600) do |file|
94
+ file.binmode
95
+ file.chmod(0o600)
96
+ file.write(bytes)
97
+ file.flush
98
+ file.fsync
99
+ end
100
+ end
101
+ { status: :exported, format: :device_profile, count: count, backup: bytes, warnings: [WARNING] }
102
+ end
103
+
104
+ private_class_method def self.profile_position(opts = {})
105
+ value = opts[:value]
106
+ raise ArgumentError unless value.is_a?(Hash) || value.is_a?(Meshtastic::Position)
107
+
108
+ value = Meshtastic::Position.new(value) if value.is_a?(Hash)
109
+ validate_wire(bytes: value.to_proto, descriptor: Meshtastic::Position.descriptor)
110
+ Meshtastic::Position.decode(value.to_proto)
111
+ rescue ArgumentError, TypeError, RangeError, Google::Protobuf::Error
112
+ raise ArgumentError, 'fixed_position must be a valid Position protobuf or field Hash', cause: nil
113
+ end
114
+
115
+ public_class_method def self.import(opts = {})
116
+ validate_options(options: opts, operation: :import)
117
+ connection = connection_options(opts)
118
+ raise ArgumentError, 'supply exactly one of path or backup' unless opts.key?(:path) ^ opts.key?(:backup)
119
+
120
+ document = opts[:backup]
121
+ if opts.key?(:path)
122
+ document = File.open(opts[:path], File::RDONLY | File::NOFOLLOW) do |file|
123
+ raise ArgumentError, 'backup path must be a regular file' unless file.stat.file?
124
+
125
+ file.binmode
126
+ file.read
127
+ end
128
+ end
129
+ entries = import_plan(document: document, format: opts.fetch(:format, :auto), path: opts[:path])
130
+ plan = entries.each_with_index.sort_by do |entry, index|
131
+ priority = if entry[:section] == 'config'
132
+ { 'LORA_CONFIG' => 20, 'BLUETOOTH_CONFIG' => 21, 'NETWORK_CONFIG' => 22, 'SECURITY_CONFIG' => 23 }.fetch(entry[:slot], 0)
133
+ else
134
+ entry[:section] == 'channel' ? 10 : 0
135
+ end
136
+ [priority, index]
137
+ end.map(&:first)
138
+ report = { status: :dry_run, planned: plan.length, attempted: 0, acknowledged: 0, readback_confirmed: 0, persistence_verified: false,
139
+ transaction: :not_requested, records: [], plan: plan.map { |entry| entry.slice(:section, :slot) }, warnings: [WARNING] }
140
+ return report if opts.fetch(:dry_run, false)
141
+
142
+ failure = nil
143
+ if opts.fetch(:edit_transaction, false) && !plan.empty?
144
+ failure = { operation: :begin_edit_settings }
145
+ report[:transaction] = :begin_uncertain
146
+ Admin.request(connection.merge(begin_edit_settings: true))
147
+ report[:transaction] = :open
148
+ end
149
+ plan.each do |entry|
150
+ field = { 'owner' => :set_owner, 'config' => :set_config, 'module_config' => :set_module_config,
151
+ 'channel' => :set_channel, 'ui' => :store_ui_config, 'fixed_position' => :set_fixed_position,
152
+ 'ringtone' => :set_ringtone_message, 'canned_messages' => :set_canned_message_module_messages }.fetch(entry[:section])
153
+ failure = entry.slice(:section, :slot).merge(operation: field)
154
+ report[:attempted] += 1
155
+ status = apply_entry(connection: connection, field: field, entry: entry, failure: failure)
156
+ report[status] += 1
157
+ report[:records] << entry.slice(:section, :slot).merge(status: status)
158
+ end
159
+ if report[:transaction] == :open
160
+ failure = { operation: :commit_edit_settings }
161
+ report[:transaction] = :commit_uncertain
162
+ Admin.request(connection.merge(commit_edit_settings: true))
163
+ report[:transaction] = :commit_acknowledged
164
+ end
165
+ report[:status] = report[:readback_confirmed].positive? ? :applied : :acknowledged
166
+ verify_readback(connection: connection, plan: plan, report: report) if opts.fetch(:verify, false)
167
+ report
168
+ rescue StandardError => e
169
+ raise unless failure
170
+
171
+ report[:status] = :partial_failure
172
+ report[:failure] = failure.merge(error: e.class.name)
173
+ report[:failure][:reason] = e.reason if e.is_a?(Admin::RoutingError)
174
+ report
175
+ end
176
+
177
+ # A timed-out mutation is never resent; only a fresh getter can resolve it.
178
+ private_class_method def self.apply_entry(opts = {})
179
+ entry = opts[:entry]
180
+ Admin.request(opts[:connection].merge(opts[:field] => entry[:message]))
181
+ :acknowledged
182
+ rescue Timeout::Error
183
+ command = read_command(entry)
184
+ opts[:failure][:readback] = :unsupported
185
+ raise unless command
186
+
187
+ opts[:failure][:readback] = :failed
188
+ actual = Admin.request(opts[:connection].merge(command))[:value]
189
+ opts[:failure][:readback] = :mismatch
190
+ raise unless readback_matches?(entry: entry, actual: actual)
191
+
192
+ :readback_confirmed
193
+ end
194
+
195
+ private_class_method def self.import_plan(opts = {})
196
+ document = opts[:document]
197
+ format = opts[:format]
198
+ if format == :auto
199
+ format = :json if document.is_a?(Hash) || File.extname(opts[:path].to_s).downcase == '.json'
200
+ format = :device_profile if File.extname(opts[:path].to_s).downcase == '.cfg'
201
+ end
202
+ return profile_plan(bytes: document) if format == :device_profile
203
+
204
+ if format == :auto
205
+ begin
206
+ return profile_plan(bytes: document)
207
+ rescue ArgumentError
208
+ raise unless document.is_a?(String) && document.b.lstrip.start_with?('{', '[')
209
+ end
210
+ end
211
+ document = JSON.parse(document, object_class: StrictObject, allow_duplicate_key: false) if document.is_a?(String)
212
+ validate_document(document: document)
213
+ rescue JSON::ParserError
214
+ raise ArgumentError, 'invalid backup JSON', cause: nil
215
+ end
216
+
217
+ # Protobuf decoding alone silently accepts unknown tags and last-wins duplicates.
218
+ private_class_method def self.validate_wire(opts = {})
219
+ bytes = opts[:bytes]
220
+ descriptor = opts[:descriptor]
221
+ depth = opts.fetch(:depth, 0)
222
+ raise ArgumentError, 'invalid protobuf size or nesting' unless bytes.is_a?(String) && bytes.bytesize <= 1_048_576 && depth <= 32
223
+
224
+ stream = StringIO.new(bytes.b)
225
+ fields = descriptor.to_h { |field| [field.number, field] }
226
+ oneofs = {}
227
+ descriptor.each_oneof { |oneof| oneof.each { |field| oneofs[field.number] = oneof.name } }
228
+ seen = []
229
+ until stream.eof?
230
+ tag = wire_varint(stream: stream)
231
+ field = fields[tag >> 3]
232
+ raise ArgumentError, 'unknown protobuf field' unless field
233
+
234
+ identity = oneofs.fetch(field.number, field.number)
235
+ raise ArgumentError, 'duplicate protobuf field or conflicting oneof' if field.label != :repeated && seen.include?(identity)
236
+
237
+ seen << identity
238
+ wire = tag & 7
239
+ expected = case field.type
240
+ when :double, :fixed64, :sfixed64 then 1
241
+ when :string, :bytes, :message then 2
242
+ when :float, :fixed32, :sfixed32 then 5
243
+ else 0
244
+ end
245
+ packed = field.label == :repeated && wire == 2 && expected != 2
246
+ raise ArgumentError, 'invalid protobuf wire type' unless wire == expected || packed
247
+
248
+ if wire == 2
249
+ length = wire_varint(stream: stream)
250
+ raise ArgumentError, 'truncated protobuf field' if length > stream.size - stream.pos
251
+
252
+ payload = stream.read(length)
253
+ if field.type == :message
254
+ validate_wire(bytes: payload, descriptor: field.subtype, depth: depth + 1)
255
+ elsif packed
256
+ packed_stream = StringIO.new(payload)
257
+ wire_scalar(stream: packed_stream, field: field, wire: expected) until packed_stream.eof?
258
+ end
259
+ else
260
+ wire_scalar(stream: stream, field: field, wire: wire)
261
+ end
262
+ end
263
+ end
264
+
265
+ private_class_method def self.wire_varint(opts = {})
266
+ value = 0
267
+ 10.times do |index|
268
+ byte = opts[:stream].getbyte
269
+ raise ArgumentError, 'invalid protobuf varint' if byte.nil? || (index == 9 && byte > 1)
270
+
271
+ value |= (byte & 127) << (index * 7)
272
+ next unless byte < 128
273
+
274
+ raise ArgumentError, 'noncanonical protobuf varint' if index.positive? && byte.zero?
275
+
276
+ return value
277
+ end
278
+ raise ArgumentError, 'invalid protobuf varint'
279
+ end
280
+
281
+ private_class_method def self.wire_scalar(opts = {})
282
+ stream = opts[:stream]
283
+ field = opts[:field]
284
+ if opts[:wire].zero?
285
+ value = wire_varint(stream: stream)
286
+ invalid = field.type == :bool && value > 1
287
+ invalid ||= %i[uint32 sint32].include?(field.type) && value > 0xffffffff
288
+ invalid ||= %i[int32 enum].include?(field.type) && value > 0x7fffffff && value < 0xffffffff80000000
289
+ if field.type == :enum
290
+ signed = value >= 0x8000000000000000 ? value - 0x10000000000000000 : value
291
+ invalid ||= field.subtype.lookup_value(signed).nil?
292
+ end
293
+ raise ArgumentError, 'invalid protobuf scalar or unknown enum' if invalid
294
+ else
295
+ length = opts[:wire] == 1 ? 8 : 4
296
+ bytes = stream.read(length)
297
+ raise ArgumentError, 'truncated protobuf scalar' unless bytes && bytes.bytesize == length
298
+ raise ArgumentError, 'nonfinite protobuf float' if %i[float double].include?(field.type) && !bytes.unpack1(length == 4 ? 'e' : 'E').finite?
299
+ end
300
+ end
301
+
302
+ private_class_method def self.profile_plan(opts = {})
303
+ validate_wire(bytes: opts[:bytes], descriptor: Meshtastic::DeviceProfile.descriptor)
304
+ profile = Meshtastic::DeviceProfile.decode(opts[:bytes])
305
+ entries = []
306
+ owner = (OWNER_FIELDS + ['is_unmessagable']).select { |name| profile.public_send("has_#{name}?") }
307
+ unless owner.empty?
308
+ values = owner.to_h { |name| [name.to_sym, profile[name]] }
309
+ entries << { section: 'owner', slot: nil, message: Meshtastic::User.new(values), owner_fields: owner }
310
+ end
311
+ %w[fixed_position ringtone canned_messages].each do |name|
312
+ entries << { section: name, slot: nil, message: profile[name] } if profile.public_send("has_#{name}?")
313
+ end
314
+ [[profile.config, Meshtastic::Config, CONFIG_TYPES, 'config'],
315
+ [profile.module_config, Meshtastic::ModuleConfig, MODULE_TYPES, 'module_config']].each do |local, klass, types, section|
316
+ next unless local
317
+
318
+ klass.descriptor.to_a.take(types.length).each_with_index do |field, index|
319
+ value = local[field.name]
320
+ next unless value
321
+
322
+ entries << { section: section, slot: types[index].to_s, message: klass.new(field.name.to_sym => value) }
323
+ end
324
+ end
325
+ if profile.has_channel_url?
326
+ uri = URI.parse(profile.channel_url)
327
+ raise ArgumentError, 'channel URL queries are unsupported' if uri.query
328
+
329
+ channel_set = Admin::Channel.import_url(url: profile.channel_url)
330
+ payload = Base64.urlsafe_decode64(uri.fragment)
331
+ raise ArgumentError, 'noncanonical channel URL base64' unless Base64.urlsafe_encode64(payload, padding: false) == uri.fragment.delete_suffix('==').delete_suffix('=')
332
+
333
+ validate_wire(bytes: payload, descriptor: Meshtastic::ChannelSet.descriptor)
334
+ channel_set.settings.each_with_index do |settings, index|
335
+ channel = Admin::Channel.build(index: index, role: index.zero? ? :PRIMARY : :SECONDARY, settings: settings)
336
+ entries << { section: 'channel', slot: index, message: channel }
337
+ end
338
+ if channel_set.lora_config
339
+ lora = entries.find { |entry| entry[:section] == 'config' && entry[:slot] == 'LORA_CONFIG' }
340
+ message = Meshtastic::Config.new(lora: channel_set.lora_config)
341
+ raise ArgumentError, 'conflicting profile and channel URL LoRa configuration' if lora && lora[:message] != message
342
+
343
+ entries << { section: 'config', slot: 'LORA_CONFIG', message: message } unless lora
344
+ end
345
+ end
346
+ raise ArgumentError, 'DeviceProfile contains no restorable fields' if entries.empty?
347
+
348
+ entries
349
+ rescue Google::Protobuf::ParseError, TypeError, RangeError, URI::InvalidURIError
350
+ raise ArgumentError, 'invalid DeviceProfile protobuf or channel URL', cause: nil
351
+ end
352
+
353
+ private_class_method def self.verify_readback(opts = {})
354
+ report = opts[:report]
355
+ report[:readback_matched] = 0
356
+ opts[:plan].each_with_index do |entry, index|
357
+ command = read_command(entry)
358
+ unless command
359
+ report[:records][index][:readback] = :unsupported
360
+ next
361
+ end
362
+ actual = Admin.request(opts[:connection].merge(command))[:value]
363
+ matched = readback_matches?(entry: entry, actual: actual)
364
+ report[:records][index][:readback] = matched ? :matched : :mismatch
365
+ report[:readback_matched] += 1 if matched
366
+ rescue StandardError => e
367
+ report[:records][index][:readback] = :failed
368
+ report[:records][index][:readback_error] = e.class.name
369
+ end
370
+ report[:status] = report[:readback_matched] == opts[:plan].length ? :readback_matched : :readback_incomplete
371
+ end
372
+
373
+ private_class_method def self.readback_matches?(opts = {})
374
+ entry = opts[:entry]
375
+ actual = opts[:actual]
376
+ if entry[:section] == 'owner'
377
+ fields = entry.fetch(:owner_fields, OWNER_FIELDS).select do |name|
378
+ !Meshtastic::User.descriptor.lookup(name).has_presence? || actual.public_send("has_#{name}?")
379
+ end
380
+ actual = Meshtastic::User.new(fields.to_h { |name| [name.to_sym, actual[name]] })
381
+ end
382
+ actual == entry[:message]
383
+ end
384
+
385
+ private_class_method def self.validate_document(opts = {})
386
+ document = opts[:document]
387
+ raise ArgumentError, 'invalid backup document or version' unless document.is_a?(Hash) && document.keys.sort == %w[format records version warning] && document['format'] == FORMAT && document['version'].is_a?(Integer) && document['version'] == 1 && document['warning'].is_a?(String) && document['records'].is_a?(Array)
388
+
389
+ seen = []
390
+ document['records'].map do |record|
391
+ raise ArgumentError, 'invalid backup record' unless record.is_a?(Hash) && record.keys.sort == %w[section slot value]
392
+
393
+ section = record['section']
394
+ slot = record['slot']
395
+ identity = [section, slot]
396
+ raise ArgumentError, 'duplicate backup slot' if seen.include?(identity)
397
+
398
+ seen << identity
399
+ klass = case section
400
+ when 'owner'
401
+ raise ArgumentError, 'invalid owner slot or identity fields' unless slot.nil? && record['value'].is_a?(Hash) && (record['value'].keys - OWNER_FIELDS).empty?
402
+
403
+ Meshtastic::User
404
+ when 'config'
405
+ raise ArgumentError, 'invalid config slot' unless CONFIG_TYPES.map(&:to_s).include?(slot)
406
+
407
+ Meshtastic::Config
408
+ when 'module_config'
409
+ raise ArgumentError, 'invalid module slot' unless MODULE_TYPES.map(&:to_s).include?(slot)
410
+
411
+ Meshtastic::ModuleConfig
412
+ when 'channel'
413
+ raise ArgumentError, 'invalid channel slot' unless slot.is_a?(Integer) && slot.between?(0, 7)
414
+
415
+ Meshtastic::Channel
416
+ when 'ui'
417
+ raise ArgumentError, 'invalid UI slot' unless slot.nil?
418
+
419
+ Meshtastic::DeviceUIConfig
420
+ else
421
+ raise ArgumentError, 'unknown backup section'
422
+ end
423
+ validate_proto_json(value: record['value'], descriptor: klass.descriptor)
424
+ message = klass.decode_json(JSON.generate(record['value']), ignore_unknown_fields: false)
425
+ if %w[config module_config].include?(section)
426
+ types = section == 'config' ? CONFIG_TYPES : MODULE_TYPES
427
+ fields = klass.descriptor.map(&:name)
428
+ expected = fields[types.index(slot.to_sym)]
429
+ raise ArgumentError, 'protobuf section does not match slot' unless message.payload_variant.to_s == expected
430
+ end
431
+ raise ArgumentError, 'channel index does not match slot' if section == 'channel' && message.index != slot
432
+
433
+ { section: section, slot: slot, message: message }
434
+ end
435
+ rescue Google::Protobuf::ParseError, TypeError, RangeError
436
+ raise ArgumentError, 'invalid protobuf JSON in backup'
437
+ end
438
+
439
+ private_class_method def self.validate_proto_json(opts = {})
440
+ value = opts[:value]
441
+ descriptor = opts[:descriptor]
442
+ raise ArgumentError, 'protobuf value must be an object' unless value.is_a?(Hash)
443
+
444
+ value.each do |name, item|
445
+ field = descriptor.lookup(name) if name.is_a?(String)
446
+ raise ArgumentError, 'unknown protobuf field' unless field
447
+ raise ArgumentError, 'null protobuf values are not supported' if item.nil?
448
+
449
+ items = field.label == :repeated ? item : [item]
450
+ raise ArgumentError, 'repeated protobuf field must be an array' unless items.is_a?(Array)
451
+
452
+ items.each do |entry|
453
+ validate_proto_json(value: entry, descriptor: field.subtype) if field.type == :message
454
+ next unless field.type == :bytes
455
+
456
+ raise ArgumentError, 'bytes must use canonical base64' unless entry.is_a?(String) && Base64.strict_encode64(Base64.strict_decode64(entry)) == entry
457
+ end
458
+ end
459
+ end
460
+
461
+ private_class_method def self.validate_options(opts = {})
462
+ options = opts[:options]
463
+ allowed = %i[transport_obj to timeout channel hop_limit path]
464
+ allowed += opts[:operation] == :export ? %i[format config_types module_config_types channel_indexes include_owner include_ui] : %i[backup format dry_run edit_transaction verify]
465
+ allowed += %i[fixed_position include_ringtone include_canned_messages] if opts[:operation] == :export && options[:format] == :device_profile
466
+ raise ArgumentError, 'unknown backup options' unless (options.keys - allowed).empty?
467
+
468
+ formats = opts[:operation] == :export ? %i[json device_profile] : %i[auto json device_profile]
469
+ raise ArgumentError, "format must be one of #{formats.join(', ')}" if options.key?(:format) && !formats.include?(options[:format])
470
+
471
+ %i[include_owner include_ui include_ringtone include_canned_messages dry_run edit_transaction verify].each do |key|
472
+ raise ArgumentError, 'backup flags must be boolean' if options.key?(key) && ![true, false].include?(options[key])
473
+ end
474
+ timeout = options.fetch(:timeout, 10)
475
+ raise ArgumentError, 'timeout must be positive and finite' unless timeout.is_a?(Numeric) && timeout.positive? && timeout.finite?
476
+ end
477
+
478
+ private_class_method def self.connection_options(opts = {})
479
+ raise ArgumentError, 'MQTT synchronous backup is unsupported' if Admin.transport_type(opts) == :mqtt
480
+
481
+ opts.slice(:transport_obj, :to, :timeout, :channel, :hop_limit)
482
+ end
483
+
484
+ private_class_method def self.selection(opts = {})
485
+ configs = opts.fetch(:config_types, CONFIG_TYPES)
486
+ modules = opts.fetch(:module_config_types, [])
487
+ channels = opts.fetch(:channel_indexes, (0..7).to_a)
488
+ raise ArgumentError, 'invalid config_types' unless configs.is_a?(Array) && (configs - CONFIG_TYPES).empty? && configs.uniq == configs
489
+ raise ArgumentError, 'invalid module_config_types' unless modules.is_a?(Array) && (modules - MODULE_TYPES).empty? && modules.uniq == modules
490
+ raise ArgumentError, 'invalid channel_indexes' unless channels.is_a?(Array) && channels.all? { |i| i.is_a?(Integer) && i.between?(0, 7) } && channels.uniq == channels
491
+
492
+ records = []
493
+ records << ['owner', nil] if opts.fetch(:include_owner, true)
494
+ configs.each { |type| records << ['config', type.to_s] }
495
+ modules.each { |type| records << ['module_config', type.to_s] }
496
+ channels.each { |index| records << ['channel', index] }
497
+ records << ['ui', nil] if opts.fetch(:include_ui, false)
498
+ records << ['ringtone', nil] if opts.fetch(:include_ringtone, false)
499
+ records << ['canned_messages', nil] if opts.fetch(:include_canned_messages, false)
500
+ records
501
+ end
502
+
503
+ private_class_method def self.read_command(opts = {})
504
+ case opts[:section]
505
+ when 'owner' then { get_owner_request: true }
506
+ when 'config' then { get_config_request: opts[:slot].to_sym }
507
+ when 'module_config' then { get_module_config_request: opts[:slot].to_sym }
508
+ when 'channel' then { get_channel_request: opts[:slot] + 1 }
509
+ when 'ui' then { get_ui_config_request: true }
510
+ when 'ringtone' then { get_ringtone_request: true }
511
+ when 'canned_messages' then { get_canned_message_module_messages_request: true }
512
+ end
513
+ end
514
+
515
+ public_class_method def self.authors
516
+ "AUTHOR(S):\n 0day Inc. <support@0dayinc.com>\n"
517
+ end
518
+
519
+ public_class_method def self.help
520
+ puts "USAGE:
521
+ # Export fresh selected configuration sections.
522
+ #{self}.export(
523
+ transport_obj: 'required - connected Serial, Bluetooth or TCP handle',
524
+ format: 'optional - :json (default version-1 Hash) or :device_profile (binary String in result[:backup]); result also has status, count and warnings',
525
+ path: 'optional - new secret JSON or binary .cfg file matching format; never overwritten; mode 0600',
526
+ to: 'optional - explicit unicast target; defaults to local node',
527
+ timeout: 'optional - positive finite per-request seconds; default 10',
528
+ config_types: 'optional - ConfigType symbols; default eight writable core sections',
529
+ module_config_types: 'optional - ModuleConfigType symbols; default empty',
530
+ channel_indexes: 'optional - unique zero-based indexes 0 through 7; default all eight; binary requires contiguous prefix from 0 with PRIMARY then SECONDARY roles, no disabled slots',
531
+ include_owner: 'optional - portable owner names and license flag; binary also preserves present is_unmessagable; default true',
532
+ include_ui: 'optional - dedicated UI configuration; default false; true is rejected for binary before requests',
533
+ fixed_position: 'optional - binary only: explicit Position protobuf or field Hash; no getter exists; omitted by default',
534
+ include_ringtone: 'optional - binary only: fresh ringtone including empty string; boolean default false',
535
+ include_canned_messages: 'optional - binary only: fresh canned messages including empty string; boolean default false',
536
+ channel: 'optional - mesh transport channel index; default Admin behavior',
537
+ hop_limit: 'optional - mesh transport hop limit; default Admin behavior'
538
+ )
539
+ # Restore with bounded ACKs or exact timeout readback; never replay writes.
540
+ #{self}.import(
541
+ transport_obj: 'required - connected Serial, Bluetooth or TCP handle; no MQTT',
542
+ path: 'optional - existing JSON or binary DeviceProfile .cfg file; mutually exclusive with backup',
543
+ backup: 'optional - versioned JSON Hash, JSON String or binary DeviceProfile String; mutually exclusive with path',
544
+ format: 'optional - :auto (default), :json or :device_profile; cfg paths select binary, other content is validated',
545
+ dry_run: 'optional - validate and plan without any radio requests; default false',
546
+ edit_transaction: 'optional - begin/commit on firmware known to support edits; default false',
547
+ verify: 'optional - additional fresh comparison after completed writes/commit; timeout recovery reads occur regardless; not durability proof; default false',
548
+ timeout: 'optional - positive finite seconds per request; default 10',
549
+ to: 'optional - explicit unicast target; defaults to connected local node',
550
+ channel: 'optional - mesh transport channel index; default Admin behavior',
551
+ hop_limit: 'optional - mesh transport hop limit; default Admin behavior'
552
+ )
553
+ # Contains secrets; firmware may redact values. Never logs document contents.
554
+ # Display the module authors.
555
+ #{self}.authors
556
+ "
557
+ end
558
+ end
559
+ end
560
+ end
@@ -769,3 +769,5 @@ end
769
769
  require 'meshtastic/admin/firmware'
770
770
  require 'meshtastic/admin/channel'
771
771
  require 'meshtastic/admin/config'
772
+
773
+ Meshtastic::Admin.autoload :Backup, 'meshtastic/admin/backup'