meshtastic 0.0.178 → 0.0.180

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 (119) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +3 -175
  3. data/documentation/README.md +60 -0
  4. data/documentation/admin-channel.md +33 -0
  5. data/documentation/admin-config.md +28 -0
  6. data/documentation/admin-firmware.md +69 -0
  7. data/documentation/admin.md +55 -0
  8. data/documentation/apponly.md +22 -0
  9. data/documentation/atak.md +110 -0
  10. data/documentation/bluetooth-bluez.md +35 -0
  11. data/documentation/bluetooth.md +87 -0
  12. data/documentation/cannedmessages.md +25 -0
  13. data/documentation/channel.md +3 -0
  14. data/documentation/clientonly.md +21 -0
  15. data/documentation/config.md +3 -0
  16. data/documentation/connection-status.md +22 -0
  17. data/documentation/deviceonly.md +22 -0
  18. data/documentation/localonly.md +21 -0
  19. data/documentation/mesh-interface.md +52 -0
  20. data/documentation/meshtastic.md +45 -0
  21. data/documentation/module-config.md +26 -0
  22. data/documentation/mqtt.md +101 -0
  23. data/documentation/paxcount.md +25 -0
  24. data/documentation/portnums.md +67 -0
  25. data/documentation/position.md +34 -0
  26. data/documentation/protobufs.md +162 -0
  27. data/documentation/remote-hardware.md +33 -0
  28. data/documentation/rtttl.md +24 -0
  29. data/documentation/serial.md +78 -0
  30. data/documentation/storeforward.md +24 -0
  31. data/documentation/stream-interface.md +15 -0
  32. data/documentation/tcp.md +43 -0
  33. data/documentation/telemetry.md +32 -0
  34. data/documentation/traceroute.md +28 -0
  35. data/documentation/util.md +27 -0
  36. data/documentation/xmodem.md +22 -0
  37. data/lib/meshtastic/admin/channel.rb +90 -0
  38. data/lib/meshtastic/admin/config.rb +154 -0
  39. data/lib/meshtastic/admin/firmware.rb +173 -0
  40. data/lib/meshtastic/admin.rb +495 -7
  41. data/lib/meshtastic/apponly.rb +26 -8
  42. data/lib/meshtastic/atak.rb +298 -7
  43. data/lib/meshtastic/bluetooth/bluez.rb +20 -1
  44. data/lib/meshtastic/bluetooth.rb +70 -59
  45. data/lib/meshtastic/cannedmessages.rb +23 -8
  46. data/lib/meshtastic/channel.rb +3 -21
  47. data/lib/meshtastic/clientonly.rb +26 -8
  48. data/lib/meshtastic/config.rb +3 -21
  49. data/lib/meshtastic/connection_status.rb +13 -8
  50. data/lib/meshtastic/deviceonly.rb +23 -8
  51. data/lib/meshtastic/localonly.rb +23 -8
  52. data/lib/meshtastic/mesh_interface.rb +11 -0
  53. data/lib/meshtastic/module_config.rb +18 -9
  54. data/lib/meshtastic/mqtt.rb +72 -35
  55. data/lib/meshtastic/paxcount.rb +30 -9
  56. data/lib/meshtastic/portnums.rb +18 -8
  57. data/lib/meshtastic/position.rb +45 -0
  58. data/lib/meshtastic/remote_hardware.rb +50 -9
  59. data/lib/meshtastic/rtttl.rb +26 -8
  60. data/lib/meshtastic/serial.rb +81 -70
  61. data/lib/meshtastic/storeforward.rb +23 -8
  62. data/lib/meshtastic/stream_interface.rb +11 -0
  63. data/lib/meshtastic/tcp.rb +153 -0
  64. data/lib/meshtastic/telemetry.rb +27 -9
  65. data/lib/meshtastic/traceroute.rb +42 -0
  66. data/lib/meshtastic/util.rb +3 -1
  67. data/lib/meshtastic/version.rb +1 -1
  68. data/lib/meshtastic/xmodem.rb +17 -8
  69. data/lib/meshtastic.rb +46 -6
  70. data/spec/conventions_spec.rb +321 -0
  71. data/spec/lib/meshtastic/admin/channel_spec.rb +45 -0
  72. data/spec/lib/meshtastic/admin/config_spec.rb +48 -0
  73. data/spec/lib/meshtastic/admin/firmware_spec.rb +118 -0
  74. data/spec/lib/meshtastic/admin_pb_spec.rb +1 -1
  75. data/spec/lib/meshtastic/admin_spec.rb +79 -0
  76. data/spec/lib/meshtastic/apponly_pb_spec.rb +1 -1
  77. data/spec/lib/meshtastic/apponly_spec.rb +4 -0
  78. data/spec/lib/meshtastic/atak_pb_spec.rb +1 -1
  79. data/spec/lib/meshtastic/atak_spec.rb +147 -0
  80. data/spec/lib/meshtastic/bluetooth_spec.rb +1 -1
  81. data/spec/lib/meshtastic/cannedmessages_pb_spec.rb +1 -1
  82. data/spec/lib/meshtastic/cannedmessages_spec.rb +3 -0
  83. data/spec/lib/meshtastic/channel_pb_spec.rb +1 -1
  84. data/spec/lib/meshtastic/channel_spec.rb +3 -0
  85. data/spec/lib/meshtastic/clientonly_pb_spec.rb +1 -1
  86. data/spec/lib/meshtastic/clientonly_spec.rb +5 -0
  87. data/spec/lib/meshtastic/config_pb_spec.rb +1 -1
  88. data/spec/lib/meshtastic/config_spec.rb +4 -1
  89. data/spec/lib/meshtastic/connection_status_pb_spec.rb +1 -1
  90. data/spec/lib/meshtastic/connection_status_spec.rb +3 -0
  91. data/spec/lib/meshtastic/deviceonly_legacy_pb_spec.rb +1 -1
  92. data/spec/lib/meshtastic/deviceonly_pb_spec.rb +1 -1
  93. data/spec/lib/meshtastic/deviceonly_spec.rb +3 -0
  94. data/spec/lib/meshtastic/localonly_pb_spec.rb +1 -1
  95. data/spec/lib/meshtastic/localonly_spec.rb +3 -0
  96. data/spec/lib/meshtastic/mesh_beacon_pb_spec.rb +1 -1
  97. data/spec/lib/meshtastic/mesh_pb_spec.rb +1 -1
  98. data/spec/lib/meshtastic/module_config_pb_spec.rb +1 -1
  99. data/spec/lib/meshtastic/module_config_spec.rb +21 -0
  100. data/spec/lib/meshtastic/mqtt_pb_spec.rb +1 -1
  101. data/spec/lib/meshtastic/paxcount_pb_spec.rb +1 -1
  102. data/spec/lib/meshtastic/paxcount_spec.rb +23 -0
  103. data/spec/lib/meshtastic/portnums_pb_spec.rb +1 -1
  104. data/spec/lib/meshtastic/portnums_spec.rb +4 -0
  105. data/spec/lib/meshtastic/position_spec.rb +33 -0
  106. data/spec/lib/meshtastic/remote_hardware_pb_spec.rb +1 -1
  107. data/spec/lib/meshtastic/remote_hardware_spec.rb +24 -0
  108. data/spec/lib/meshtastic/rtttl_pb_spec.rb +1 -1
  109. data/spec/lib/meshtastic/rtttl_spec.rb +21 -0
  110. data/spec/lib/meshtastic/storeforward_pb_spec.rb +1 -1
  111. data/spec/lib/meshtastic/storeforward_spec.rb +21 -0
  112. data/spec/lib/meshtastic/tcp_spec.rb +48 -0
  113. data/spec/lib/meshtastic/telemetry_pb_spec.rb +1 -1
  114. data/spec/lib/meshtastic/telemetry_spec.rb +21 -0
  115. data/spec/lib/meshtastic/traceroute_spec.rb +27 -0
  116. data/spec/lib/meshtastic/xmodem_pb_spec.rb +1 -1
  117. data/spec/lib/meshtastic/xmodem_spec.rb +6 -1
  118. data/spec/lib/meshtastic_spec.rb +3 -0
  119. metadata +48 -1
data/lib/meshtastic.rb CHANGED
@@ -12,11 +12,13 @@ module Meshtastic
12
12
  require 'meshtastic/clientonly_pb'
13
13
  require 'meshtastic/config_pb'
14
14
  require 'meshtastic/connection_status_pb'
15
+ require 'meshtastic/deviceonly_legacy_pb'
15
16
  require 'meshtastic/deviceonly_pb'
16
17
  require 'meshtastic/device_ui_pb'
17
18
  require 'meshtastic/interdevice_pb'
18
19
  require 'meshtastic/localonly_pb'
19
20
  require 'meshtastic/lorawan_bridge_pb'
21
+ require 'meshtastic/mesh_beacon_pb'
20
22
  require 'meshtastic/mesh_pb'
21
23
  require 'meshtastic/module_config_pb'
22
24
  require 'meshtastic/mqtt_pb'
@@ -25,6 +27,7 @@ module Meshtastic
25
27
  require 'meshtastic/powermon_pb'
26
28
  require 'meshtastic/remote_hardware_pb'
27
29
  require 'meshtastic/rtttl_pb'
30
+ require 'meshtastic/serial_hal_pb'
28
31
  require 'meshtastic/storeforward_pb'
29
32
  require 'meshtastic/telemetry_pb'
30
33
  require 'meshtastic/version'
@@ -38,23 +41,20 @@ module Meshtastic
38
41
  autoload :ATAK, 'meshtastic/atak'
39
42
  autoload :Bluetooth, 'meshtastic/bluetooth'
40
43
  autoload :Cannedmessages, 'meshtastic/cannedmessages'
41
- autoload :Channel, 'meshtastic/channel'
42
44
  autoload :Clientonly, 'meshtastic/clientonly'
43
- autoload :Config, 'meshtastic/config'
44
45
  autoload :ConnectionStatus, 'meshtastic/connection_status'
45
46
  autoload :Deviceonly, 'meshtastic/deviceonly'
46
47
  autoload :Localonly, 'meshtastic/localonly'
47
48
  autoload :MeshInterface, 'meshtastic/mesh_interface'
48
- autoload :ModuleConfig, 'meshtastic/module_config'
49
49
  autoload :MQTT, 'meshtastic/mqtt'
50
- autoload :Paxcount, 'meshtastic/paxcount'
51
50
  autoload :Portnums, 'meshtastic/portnums'
52
51
  autoload :RemoteHardware, 'meshtastic/remote_hardware'
53
52
  autoload :RTTTL, 'meshtastic/rtttl'
54
53
  autoload :Serial, 'meshtastic/serial'
55
54
  autoload :Storeforward, 'meshtastic/storeforward'
56
55
  autoload :StreamInterface, 'meshtastic/stream_interface'
57
- autoload :Telemetry, 'meshtastic/telemetry'
56
+ autoload :TCP, 'meshtastic/tcp'
57
+ autoload :Traceroute, 'meshtastic/traceroute'
58
58
  autoload :Util, 'meshtastic/util'
59
59
  autoload :Xmodem, 'meshtastic/xmodem'
60
60
 
@@ -67,7 +67,47 @@ module Meshtastic
67
67
 
68
68
  # Display a List of Every Meshtastic Module
69
69
 
70
+ public_class_method def self.authors
71
+ "AUTHOR(S):\n 0day Inc. <support@0dayinc.com>\n "
72
+ end
73
+
70
74
  public_class_method def self.help
71
- constants.sort
75
+ puts " USAGE:
76
+ # Run the deliver_data class method for this module.
77
+ #{self}.deliver_data(
78
+ data: 'optional - value for data passed into deliver_data',
79
+ serial_obj: 'optional - value for serial_obj passed into deliver_data',
80
+ bluetooth_obj: 'optional - value for bluetooth_obj passed into deliver_data',
81
+ tcp_obj: 'optional - value for tcp_obj passed into deliver_data',
82
+ mqtt_obj: 'optional - value for mqtt_obj passed into deliver_data'
83
+ )
84
+
85
+ # Run the authors class method for this module.
86
+ #{self}.authors
87
+
88
+ "
89
+ end
90
+
91
+ public_class_method def self.deliver_data(opts = {})
92
+ raise ArgumentError, 'data is required' unless opts[:data].is_a?(Meshtastic::Data)
93
+
94
+ if opts[:serial_obj]
95
+ Serial.send_data(opts)
96
+ elsif opts[:bluetooth_obj]
97
+ Bluetooth.send_data(opts)
98
+ elsif opts[:tcp_obj]
99
+ TCP.send_data(opts)
100
+ elsif opts[:mqtt_obj]
101
+ MQTT.send_data(opts)
102
+ else
103
+ raise ArgumentError, 'serial_obj, bluetooth_obj, tcp_obj, or mqtt_obj is required'
104
+ end
72
105
  end
73
106
  end
107
+
108
+ require 'meshtastic/channel'
109
+ require 'meshtastic/config'
110
+ require 'meshtastic/module_config'
111
+ require 'meshtastic/paxcount'
112
+ require 'meshtastic/position'
113
+ require 'meshtastic/telemetry'
@@ -0,0 +1,321 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'spec_helper'
4
+
5
+ # Global source-level conventions for every Ruby module under lib/meshtastic
6
+ # (same rules as /opt/pwn/spec/conventions_spec.rb).
7
+ #
8
+ # RULES
9
+ # 0. Every method on a Meshtastic namespace *module* MUST be referenced via
10
+ # `self.<method_name>` with public_class_method / private_class_method.
11
+ # Bare `def foo` at module scope fails. So does `module_function`.
12
+ # Nested `class` instance methods and class_eval / `class <<` are exempt.
13
+ # 1. Module methods MUST be declared with an explicit visibility decorator
14
+ # immediately preceding `def self.<name>` on the same line.
15
+ # 2. Methods that accept arguments MUST take exactly `(opts = {})`.
16
+ # 3. Methods declared `(opts = {})` MUST consume opts in the body.
17
+ # 4. Every leaf module file MUST define `def self.help`.
18
+ # 5. Every leaf module file MUST define `def self.authors`.
19
+ # 6. Every public class method except `help` MUST appear in that module's
20
+ # `help` method — including `authors`. Directly above each method, `help`
21
+ # MUST print a brief purpose line. Methods that take `(opts = {})` MUST
22
+ # document each `opts[:key]` they read as `required` or `optional` plus an
23
+ # operator-usable description — same shape as PWN::WWW::Google.help.
24
+
25
+ module MeshtasticConventions
26
+ ROOT = File.expand_path('..', __dir__)
27
+ LIB = File.join(ROOT, 'lib', 'meshtastic')
28
+
29
+ GLOBAL_ALLOWLIST = [
30
+ 'lib/meshtastic/version.rb' # VERSION constant only
31
+ ].freeze
32
+
33
+ NAMESPACE_INDEX_FILES = [].freeze
34
+
35
+ RB_FILES = (
36
+ Dir[File.join(LIB, '**', '*.rb')] + [File.join(ROOT, 'lib', 'meshtastic.rb')]
37
+ ).reject do |f|
38
+ rel = f.sub("#{ROOT}/", '')
39
+ GLOBAL_ALLOWLIST.include?(rel) || rel.end_with?('_pb.rb')
40
+ end.sort.freeze
41
+
42
+ MODULE_FILES = RB_FILES.select { |f| File.read(f).match?(/^\s*module\s+\w/) }.freeze
43
+
44
+ SELF_DEF_RE = /^([ \t]*)((?:public_class_method|private_class_method)\s+)?def self\.([a-z_][\w?!]*)(?:\(([^)]*)\))?/
45
+
46
+ BARE_DEF_RE = /^[ \t]*def ([a-z_][\w?!]*)(?:\(([^)]*)\))?/
47
+
48
+ MODULE_FUNCTION_RE = /^\s*module_function\b/
49
+
50
+ CLASS_LIKE_OPEN_RE = /
51
+ \Aclass\b
52
+ | \.class_eval\b
53
+ | \.module_eval\b
54
+ | \Aclass_eval\b
55
+ | \Amodule_eval\b
56
+ | \Aclass\s*<<
57
+ | \bClass\.new\b
58
+ | \bStruct\.new\b
59
+ | \bModule\.new\b
60
+ | \bData\.define\b
61
+ /x
62
+
63
+ MODULE_OPEN_RE = /\Amodule\s+[A-Z]/
64
+
65
+ module_function
66
+
67
+ def line_indent(line)
68
+ line[/^[ \t]*/].to_s.length
69
+ end
70
+
71
+ def scan_methods(path)
72
+ src = File.read(path)
73
+ lines = src.lines
74
+ out = []
75
+ lines.each_with_index do |line, idx|
76
+ m = line.match(SELF_DEF_RE)
77
+ next unless m
78
+
79
+ out << {
80
+ file: path,
81
+ line: idx + 1,
82
+ decorator: m[2]&.strip,
83
+ name: m[3],
84
+ arglist: m[4],
85
+ body_excerpt: lines[(idx + 1)..(idx + 60)]&.join.to_s,
86
+ method_body: method_body_excerpt(lines, idx)
87
+ }
88
+ end
89
+ out
90
+ end
91
+
92
+ def method_body_excerpt(lines, idx)
93
+ indent = lines[idx][/^[ \t]*/]
94
+ buf = []
95
+ ((idx + 1)...lines.length).each do |i|
96
+ break if lines[i].match?(/^#{Regexp.escape(indent)}end\b/)
97
+
98
+ buf << lines[i]
99
+ break if buf.length >= 120
100
+ end
101
+ buf.join
102
+ end
103
+
104
+ def scan_bare_defs(path)
105
+ stack = []
106
+ out = []
107
+ File.readlines(path).each_with_index do |line, idx|
108
+ stripped = line.strip
109
+ next if stripped.empty? || stripped.start_with?('#')
110
+
111
+ indent = line_indent(line)
112
+
113
+ if stripped == 'end' || stripped.match?(/\Aend\b/)
114
+ stack.pop while stack.any? && stack.last[:indent] >= indent
115
+ next
116
+ end
117
+
118
+ if stripped.match?(MODULE_OPEN_RE) && !stripped.match?(/\bend\b/)
119
+ stack.pop while stack.any? && stack.last[:indent] >= indent
120
+ stack << { type: :module, indent: indent }
121
+ next
122
+ end
123
+
124
+ if stripped.match?(CLASS_LIKE_OPEN_RE) && !stripped.match?(/\bend\b/)
125
+ stack.pop while stack.any? && stack.last[:indent] >= indent
126
+ stack << { type: :class, indent: indent }
127
+ next
128
+ end
129
+
130
+ next if line.match?(SELF_DEF_RE)
131
+
132
+ m = line.match(BARE_DEF_RE)
133
+ next unless m
134
+
135
+ container = stack.rfind { |frame| frame[:indent] < indent }
136
+ next if container.nil?
137
+ next if container[:type] == :class
138
+
139
+ out << {
140
+ file: path,
141
+ line: idx + 1,
142
+ name: m[1],
143
+ arglist: m[2]
144
+ }
145
+ end
146
+ out
147
+ end
148
+
149
+ def scan_module_function(path)
150
+ out = []
151
+ File.readlines(path).each_with_index do |line, idx|
152
+ next if line.match?(/^\s*#/)
153
+ next unless line.match?(MODULE_FUNCTION_RE)
154
+
155
+ out << { file: path, line: idx + 1 }
156
+ end
157
+ out
158
+ end
159
+
160
+ ALL_METHODS = MODULE_FILES.flat_map { |f| scan_methods(f) }.freeze
161
+ ALL_BARE_DEFS = MODULE_FILES.flat_map { |f| scan_bare_defs(f) }.freeze
162
+ ALL_MODULE_FUNCTIONS = MODULE_FILES.flat_map { |f| scan_module_function(f) }.freeze
163
+
164
+ HELP_SKIP = %w[help].freeze
165
+
166
+ def help_text(path)
167
+ lines = File.readlines(path)
168
+ idx = lines.find_index { |l| l.match?(/def self\.help\b/) }
169
+ return '' unless idx
170
+
171
+ indent = lines[idx][/^[ \t]*/]
172
+ buf = []
173
+ ((idx + 1)...lines.length).each do |i|
174
+ break if lines[i].match?(/^#{Regexp.escape(indent)}end\b/)
175
+
176
+ buf << lines[i]
177
+ end
178
+ buf.join
179
+ end
180
+
181
+ def opts_keys(body)
182
+ body.to_s.scan(/opts\[\s*:([A-Za-z_]\w*)\s*\]/).flatten.uniq
183
+ end
184
+
185
+ def thin_option_desc?(key, desc)
186
+ rest = desc.to_s.strip.sub(/\A(?:required|optional)\s*-\s*/i, '')
187
+ return true if rest.empty?
188
+
189
+ norm = rest.downcase.gsub(/[^a-z0-9]+/, ' ').strip
190
+ key_norm = key.to_s.downcase.tr('_', ' ')
191
+ return true if norm == key_norm
192
+
193
+ words = rest.split
194
+ words.length < 2 && rest.length < 18
195
+ end
196
+
197
+ def purpose_above?(help, name)
198
+ lines = help.to_s.lines
199
+ idx = lines.find_index { |l| l.match?(/\#\{self\}(?:::[A-Z]\w*)*\.#{Regexp.escape(name)}(?!\w)/) }
200
+ return false unless idx
201
+
202
+ prev = nil
203
+ (idx - 1).downto(0) do |i|
204
+ next if lines[i].strip.empty?
205
+
206
+ prev = lines[i]
207
+ break
208
+ end
209
+ return false unless prev
210
+
211
+ text = prev.strip.sub(/\A#\s*/, '')
212
+ return false if text.match?(/\AUSAGE:/i)
213
+ return false if text.match?(/\.\w+\s*\(/) || text.match?(/\#\{self\}/)
214
+ return false if text.match?(/\A\w+\s*=\s*(?:PWN|Meshtastic)::/)
215
+ return false if text.downcase.gsub(/[^a-z0-9]+/, ' ').strip == name.downcase.tr('_', ' ')
216
+
217
+ words = text.split
218
+ words.length >= 3 || text.length >= 20
219
+ end
220
+
221
+ def rel(path)
222
+ path.sub("#{ROOT}/", '')
223
+ end
224
+ end
225
+
226
+ describe 'Meshtastic module conventions' do
227
+ c = MeshtasticConventions
228
+
229
+ it '0) every Meshtastic module method is referenced via `self.<method_name>` (no bare module-level `def`, no module_function)' do
230
+ bare = c::ALL_BARE_DEFS
231
+ mfun = c::ALL_MODULE_FUNCTIONS
232
+ parts = []
233
+ unless bare.empty?
234
+ parts << 'bare module-level `def <name>` (use `public_class_method def self.<name>` / `private_class_method def self.<name>`):'
235
+ parts.concat(bare.map { |m| " #{c.rel(m[:file])}:#{m[:line]} def #{m[:name]}" })
236
+ end
237
+ unless mfun.empty?
238
+ parts << '`module_function` is forbidden (declare each method with def self.<name>):'
239
+ parts.concat(mfun.map { |m| " #{c.rel(m[:file])}:#{m[:line]} module_function" })
240
+ end
241
+ msg = parts.join("\n")
242
+ expect(bare + mfun).to be_empty, msg
243
+ end
244
+
245
+ it '1) every `def self.<name>` is decorated with public_class_method or private_class_method' do
246
+ bare = c::ALL_METHODS.select { |m| m[:decorator].nil? }
247
+ msg = bare.map { |m| " #{c.rel(m[:file])}:#{m[:line]} def self.#{m[:name]}" }.join("\n")
248
+ expect(bare).to be_empty, "bare `def self.*` (add public_class_method / private_class_method):\n#{msg}"
249
+ end
250
+
251
+ it '2) every argument-accepting module method takes exactly `(opts = {})`' do
252
+ bad = c::ALL_METHODS.reject do |m|
253
+ a = m[:arglist]
254
+ a.nil? || a.strip.empty? || a.strip == 'opts = {}'
255
+ end
256
+ msg = bad.map { |m| " #{c.rel(m[:file])}:#{m[:line]} def self.#{m[:name]}(#{m[:arglist]})" }.join("\n")
257
+ expect(bad).to be_empty, "non-conforming arglists (use `(opts = {})`):\n#{msg}"
258
+ end
259
+
260
+ it '3) methods declared `(opts = {})` actually consume opts in the body' do
261
+ unused = c::ALL_METHODS.select do |m|
262
+ m[:arglist]&.strip == 'opts = {}' &&
263
+ !m[:body_excerpt].match?(/\bopts\s*\[|\bopts\.(?:dig|fetch|key\?|keys|values|merge|each|delete|map|\[)/)
264
+ end
265
+ msg = unused.map { |m| " #{c.rel(m[:file])}:#{m[:line]} def self.#{m[:name]}(opts = {}) # opts never read" }.join("\n")
266
+ expect(unused).to be_empty, "declared `(opts = {})` but never read opts (unpack at top of method):\n#{msg}"
267
+ end
268
+
269
+ it '4) every module file defines `def self.help`' do
270
+ missing = c::MODULE_FILES.reject do |f|
271
+ c::NAMESPACE_INDEX_FILES.include?(c.rel(f)) || File.read(f).match?(/def self\.help\b/)
272
+ end
273
+ msg = missing.map { |f| " #{c.rel(f)}" }.join("\n")
274
+ expect(missing).to be_empty, "modules missing `def self.help`:\n#{msg}"
275
+ end
276
+
277
+ it '5) every module file defines `def self.authors`' do
278
+ missing = c::MODULE_FILES.reject do |f|
279
+ c::NAMESPACE_INDEX_FILES.include?(c.rel(f)) || File.read(f).match?(/def self\.authors\b/)
280
+ end
281
+ msg = missing.map { |f| " #{c.rel(f)}" }.join("\n")
282
+ expect(missing).to be_empty, "modules missing `def self.authors`:\n#{msg}"
283
+ end
284
+
285
+ it '6) every public class method is documented in help with required/optional options (Google.help shape)' do
286
+ misses = []
287
+ c::MODULE_FILES.each do |path|
288
+ next if c::NAMESPACE_INDEX_FILES.include?(c.rel(path))
289
+
290
+ help = c.help_text(path)
291
+ misses << "#{c.rel(path)} missing method authors in help" unless help.match?(/\.authors(?!\w)/)
292
+ pubs = c::ALL_METHODS.select do |m|
293
+ m[:file] == path &&
294
+ m[:decorator] == 'public_class_method' &&
295
+ !c::HELP_SKIP.include?(m[:name])
296
+ end
297
+ pubs.each do |meth|
298
+ name = meth[:name]
299
+ unless help.match?(/\.#{Regexp.escape(name)}(?!\w)/)
300
+ misses << "#{c.rel(path)} missing method #{name} in help"
301
+ next
302
+ end
303
+ misses << "#{c.rel(path)} #{name}: missing purpose line above the method in help" unless c.purpose_above?(help, name)
304
+ next unless meth[:arglist]&.strip == 'opts = {}'
305
+
306
+ c.opts_keys(meth[:method_body]).each do |key|
307
+ descs = help.scan(/#{Regexp.escape(key)}:\s*['"]((?:required|optional)\s*-\s*[^'"]+)/i).flatten
308
+ if descs.empty?
309
+ misses << "#{c.rel(path)} #{name} option #{key}: missing required/optional description"
310
+ next
311
+ end
312
+ next unless descs.any? { |d| c.thin_option_desc?(key, d) }
313
+
314
+ misses << "#{c.rel(path)} #{name} option #{key}: description too thin (say what to pass, not just the key name)"
315
+ end
316
+ end
317
+ end
318
+ extra = misses.length > 80 ? "\n... #{misses.length} total" : ''
319
+ expect(misses).to be_empty, "#{misses.first(80).join("\n")}#{extra}"
320
+ end
321
+ end
@@ -0,0 +1,45 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'spec_helper'
4
+
5
+ describe Meshtastic::Admin::Channel do
6
+ def fake_serial_obj
7
+ written = +''.b
8
+ serial_conn = Object.new
9
+ serial_conn.define_singleton_method(:write) do |b|
10
+ written << b
11
+ b.bytesize
12
+ end
13
+ serial_conn.define_singleton_method(:flush) { true }
14
+ serial_conn.define_singleton_method(:closed?) { false }
15
+ serial_conn.define_singleton_method(:close) { true }
16
+ { serial_conn: serial_conn, written: written, my_node_num: 0xb0b }
17
+ end
18
+
19
+ def decode_admin(serial_obj)
20
+ frame = serial_obj[:written]
21
+ body = frame.byteslice(4, (frame.getbyte(2) << 8) + frame.getbyte(3))
22
+ Meshtastic::AdminMessage.decode(Meshtastic::ToRadio.decode(body).packet.decoded.payload)
23
+ end
24
+
25
+ it 'requests a channel by index via Admin' do
26
+ serial_obj = fake_serial_obj
27
+ described_class.get(serial_obj: serial_obj, index: 1)
28
+ expect(decode_admin(serial_obj).get_channel_request).to eq(1)
29
+ end
30
+
31
+ it 'sets a Channel protobuf including settings and role' do
32
+ serial_obj = fake_serial_obj
33
+ settings = described_class.build_settings(name: 'LongFast', uplink_enabled: true)
34
+ described_class.set(serial_obj: serial_obj, index: 0, role: :PRIMARY, settings: settings)
35
+ channel = decode_admin(serial_obj).set_channel
36
+ expect(channel.index).to eq(0)
37
+ expect(channel.role).to eq(:PRIMARY)
38
+ expect(channel.settings.name).to eq('LongFast')
39
+ expect(channel.settings.uplink_enabled).to be true
40
+ end
41
+
42
+ it 'prints usage without raising' do
43
+ expect { described_class.help }.to output(/USAGE/).to_stdout
44
+ end
45
+ end
@@ -0,0 +1,48 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'spec_helper'
4
+
5
+ describe Meshtastic::Admin::Config do
6
+ def fake_serial_obj
7
+ written = +''.b
8
+ serial_conn = Object.new
9
+ serial_conn.define_singleton_method(:write) do |b|
10
+ written << b
11
+ b.bytesize
12
+ end
13
+ serial_conn.define_singleton_method(:flush) { true }
14
+ serial_conn.define_singleton_method(:closed?) { false }
15
+ serial_conn.define_singleton_method(:close) { true }
16
+ { serial_conn: serial_conn, written: written, my_node_num: 0xb0b }
17
+ end
18
+
19
+ def decode_admin(serial_obj)
20
+ frame = serial_obj[:written]
21
+ body = frame.byteslice(4, (frame.getbyte(2) << 8) + frame.getbyte(3))
22
+ Meshtastic::AdminMessage.decode(Meshtastic::ToRadio.decode(body).packet.decoded.payload)
23
+ end
24
+
25
+ it 'requests LoRa config via Admin' do
26
+ serial_obj = fake_serial_obj
27
+ described_class.get(serial_obj: serial_obj, config_type: :LORA_CONFIG)
28
+ expect(decode_admin(serial_obj).get_config_request).to eq(:LORA_CONFIG)
29
+ end
30
+
31
+ it 'sets a Config protobuf' do
32
+ serial_obj = fake_serial_obj
33
+ config = Meshtastic::Config.new
34
+ config.device = Meshtastic::Config::DeviceConfig.new(role: :CLIENT)
35
+ described_class.set(serial_obj: serial_obj, config: config)
36
+ expect(decode_admin(serial_obj).set_config.device.role).to eq(:CLIENT)
37
+ end
38
+
39
+ it 'requests each ConfigType via named getters' do
40
+ serial_obj = fake_serial_obj
41
+ described_class.get_lora(serial_obj: serial_obj)
42
+ expect(decode_admin(serial_obj).get_config_request).to eq(:LORA_CONFIG)
43
+ end
44
+
45
+ it 'prints usage without raising' do
46
+ expect { described_class.help }.to output(/USAGE/).to_stdout
47
+ end
48
+ end
@@ -0,0 +1,118 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'spec_helper'
4
+ require 'tempfile'
5
+ require 'digest'
6
+
7
+ describe Meshtastic::Admin::Firmware do
8
+ def fake_serial_obj
9
+ written = +''.b
10
+ serial_conn = Object.new
11
+ serial_conn.define_singleton_method(:write) do |b|
12
+ written << b
13
+ b.bytesize
14
+ end
15
+ serial_conn.define_singleton_method(:flush) { true }
16
+ serial_conn.define_singleton_method(:closed?) { false }
17
+ serial_conn.define_singleton_method(:close) { true }
18
+ { serial_conn: serial_conn, written: written, my_node_num: 0xb0b }
19
+ end
20
+
21
+ def decode_frames(serial_obj)
22
+ buf = serial_obj[:written].dup
23
+ frames = []
24
+ until buf.bytesize < 4
25
+ break unless buf.getbyte(0) == Meshtastic::START1 && buf.getbyte(1) == Meshtastic::START2
26
+
27
+ len = (buf.getbyte(2) << 8) + buf.getbyte(3)
28
+ break if buf.bytesize < 4 + len
29
+
30
+ frames << Meshtastic::ToRadio.decode(buf.byteslice(4, len))
31
+ buf = buf.byteslice((4 + len)..)
32
+ end
33
+ frames
34
+ end
35
+
36
+ def firmware_file(bytes = "hello firmware\n")
37
+ file = Tempfile.new(['fw', '.bin'])
38
+ file.binmode
39
+ file.write(bytes)
40
+ file.flush
41
+ file
42
+ end
43
+
44
+ it 'sends ota_request with SHA-256 and BLE mode' do
45
+ serial_obj = fake_serial_obj
46
+ file = firmware_file('abc')
47
+ described_class.request_ota(serial_obj: serial_obj, firmware: file.path, mode: :OTA_BLE)
48
+ packet = decode_frames(serial_obj).last.packet
49
+ admin = Meshtastic::AdminMessage.decode(packet.decoded.payload)
50
+ expect(packet.decoded.portnum).to eq(:ADMIN_APP)
51
+ expect(admin.ota_request.reboot_ota_mode).to eq(:OTA_BLE)
52
+ expect(admin.ota_request.ota_hash.bytesize).to eq(32)
53
+ expect(admin.ota_request.ota_hash).to eq(Digest::SHA256.digest('abc'))
54
+ ensure
55
+ file.close!
56
+ end
57
+
58
+ it 'sends enter_dfu_mode_request' do
59
+ serial_obj = fake_serial_obj
60
+ described_class.enter_dfu(serial_obj: serial_obj)
61
+ admin = Meshtastic::AdminMessage.decode(decode_frames(serial_obj).last.packet.decoded.payload)
62
+ expect(admin.enter_dfu_mode_request).to be true
63
+ end
64
+
65
+ it 'builds XModem SOH blocks with CRC16 and an EOT' do
66
+ blocks = described_class.xmodem_blocks(bytes: 'A' * 130)
67
+ expect(blocks.first.control).to eq(:SOH)
68
+ expect(blocks.first.seq).to eq(1)
69
+ expect(blocks.first.buffer.bytesize).to eq(128)
70
+ expect(blocks[1].seq).to eq(2)
71
+ expect(blocks.last.control).to eq(:EOT)
72
+ end
73
+
74
+ it 'streams XModem ToRadio frames after ota_request on serial' do
75
+ serial_obj = fake_serial_obj
76
+ file = firmware_file('A' * 10)
77
+ described_class.install(serial_obj: serial_obj, firmware: file.path, mode: :OTA_BLE)
78
+ frames = decode_frames(serial_obj)
79
+ admin = Meshtastic::AdminMessage.decode(frames.first.packet.decoded.payload)
80
+ expect(admin.ota_request.ota_hash.bytesize).to eq(32)
81
+ xmodem = frames[1..]
82
+ expect(xmodem.first.xmodemPacket.control).to eq(:SOH)
83
+ expect(xmodem.last.xmodemPacket.control).to eq(:EOT)
84
+ ensure
85
+ file.close!
86
+ end
87
+
88
+ it 'publishes ota_request over MQTT without XModem PhoneAPI frames' do
89
+ published = []
90
+ mqtt_obj = Object.new
91
+ mqtt_obj.define_singleton_method(:client_id) { '00000b0b' }
92
+ mqtt_obj.define_singleton_method(:publish) do |topic, payload|
93
+ published << { topic: topic, payload: payload }
94
+ payload.bytesize
95
+ end
96
+ file = firmware_file('xyz')
97
+ described_class.install(mqtt_obj: mqtt_obj, firmware: file.path, mode: :OTA_WIFI, to: '!aabbccdd')
98
+ expect(published.size).to eq(1)
99
+ envelope = Meshtastic::ServiceEnvelope.decode(published.first[:payload])
100
+ packet = envelope.packet
101
+ nonce = [packet.id].pack('V').ljust(8, "\x00") + [packet.from].pack('V').ljust(8, "\x00")
102
+ psk = Base64.strict_decode64('1PG7OiApB1nwvP+rz05pAQ==')
103
+ cipher = OpenSSL::Cipher.new('AES-128-CTR')
104
+ cipher.decrypt
105
+ cipher.key = psk
106
+ cipher.iv = nonce
107
+ data = Meshtastic::Data.decode(cipher.update(packet.encrypted) + cipher.final)
108
+ expect(data.portnum).to eq(:ADMIN_APP)
109
+ admin = Meshtastic::AdminMessage.decode(data.payload)
110
+ expect(admin.ota_request.reboot_ota_mode).to eq(:OTA_WIFI)
111
+ ensure
112
+ file.close!
113
+ end
114
+
115
+ it 'prints usage without raising' do
116
+ expect { described_class.help }.to output(/firmware/).to_stdout
117
+ end
118
+ end
@@ -2,5 +2,5 @@
2
2
 
3
3
  require 'spec_helper'
4
4
 
5
- describe Meshtastic::AdminPB do
5
+ describe Meshtastic::AdminMessage do
6
6
  end