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.
- checksums.yaml +4 -4
- data/.rubocop_todo.yml +1 -0
- data/Gemfile +2 -1
- data/documentation/README.md +6 -0
- data/documentation/admin-backup.md +369 -0
- data/documentation/admin.md +4 -0
- data/documentation/forwarder.md +149 -0
- data/documentation/mesh-interface.md +157 -1
- data/documentation/payload-formats.md +187 -0
- data/documentation/reticulum.md +89 -0
- data/lib/meshtastic/admin/backup.rb +560 -0
- data/lib/meshtastic/admin.rb +2 -0
- data/lib/meshtastic/atak.rb +98 -13
- data/lib/meshtastic/atak_pb.rb +3 -1
- data/lib/meshtastic/config_pb.rb +1 -1
- data/lib/meshtastic/field_metadata_pb.rb +1 -1
- data/lib/meshtastic/forwarder.rb +190 -0
- data/lib/meshtastic/forwarder_pb.rb +77 -0
- data/lib/meshtastic/mesh_beacon_pb.rb +1 -1
- data/lib/meshtastic/mesh_interface.rb +115 -65
- data/lib/meshtastic/mesh_pb.rb +2 -1
- data/lib/meshtastic/module_config_pb.rb +2 -1
- data/lib/meshtastic/mqtt.rb +4 -34
- data/lib/meshtastic/payload_compression.rb +90 -0
- data/lib/meshtastic/payload_formats.rb +248 -0
- data/lib/meshtastic/reticulum.rb +71 -0
- data/lib/meshtastic/serial.rb +1 -19
- data/lib/meshtastic/telemetry_pb.rb +2 -2
- data/lib/meshtastic/unishox2.rb +467 -0
- data/lib/meshtastic/version.rb +1 -1
- data/lib/meshtastic.rb +4 -0
- data/meshtastic.gemspec +2 -0
- data/spec/lib/meshtastic/admin/backup_spec.rb +704 -0
- data/spec/lib/meshtastic/atak_spec.rb +50 -0
- data/spec/lib/meshtastic/bluetooth_spec.rb +14 -0
- data/spec/lib/meshtastic/forwarder_pb_spec.rb +11 -0
- data/spec/lib/meshtastic/forwarder_spec.rb +97 -0
- data/spec/lib/meshtastic/mesh_interface_spec.rb +169 -0
- data/spec/lib/meshtastic/mqtt_spec.rb +56 -0
- data/spec/lib/meshtastic/payload_compression_spec.rb +43 -0
- data/spec/lib/meshtastic/payload_formats_spec.rb +160 -0
- data/spec/lib/meshtastic/reticulum_spec.rb +91 -0
- data/spec/lib/meshtastic/serial_spec.rb +46 -0
- data/spec/lib/meshtastic/tcp_spec.rb +15 -0
- data/spec/lib/meshtastic/unishox2_spec.rb +34 -0
- data/spec/support/payload_fixtures.rb +124 -0
- data/spec/support/reticulum_fixtures.json +12 -0
- data/spec/support/tak_codec_fixtures.rb +4 -0
- data/spec/support/unishox_fixtures.rb +4 -0
- 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
|