mychron 0.3.2

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.
@@ -0,0 +1,242 @@
1
+ # frozen_string_literal: true
2
+
3
+ module MyChron
4
+ module Discovery
5
+ class Detector
6
+ include Logging
7
+
8
+ attr_reader :interface, :subnet, :local_ip
9
+
10
+ def initialize(interface: nil, subnet: nil)
11
+ @interface = interface
12
+ @subnet = subnet
13
+ @local_ip = nil
14
+ @config = MyChron.config
15
+
16
+ detect_network_info unless @subnet
17
+ end
18
+
19
+ # Run full discovery
20
+ # Returns discovery result hash
21
+ def run(scan_ports: nil, probe_http: true)
22
+ scan_ports ||= @config.scan_ports
23
+ scan_ports = Array(scan_ports)
24
+
25
+ log_info("Network discovery started on #{@subnet} (#{@interface}), scanning #{scan_ports.length} ports")
26
+
27
+ result = {
28
+ timestamp: Time.now.utc.iso8601,
29
+ local_ip: @local_ip,
30
+ interface: @interface,
31
+ subnet: @subnet,
32
+ discovered_hosts: [],
33
+ likely_candidates: [],
34
+ wifi_aps: []
35
+ }
36
+
37
+ # Step 1: Discover hosts via ARP
38
+ arp = Network::ARP.new(interface: @interface)
39
+ hosts = arp.discover
40
+
41
+ log_info("ARP discovery complete: #{hosts.length} hosts")
42
+
43
+ result[:discovered_hosts] = hosts.map do |h|
44
+ { ip: h[:ip], mac: h[:mac], vendor: h[:vendor], hostname: h[:hostname] }
45
+ end
46
+
47
+ # Step 2: Scan WiFi APs (for AP mode detection)
48
+ result[:wifi_aps] = scan_wifi_aps
49
+
50
+ # Step 3: Port scan each host
51
+ scanner = Network::Scanner.new(timeout: @config.scan_timeout)
52
+ http_probe = Network::HttpProbe.new(timeout: @config.http_timeout)
53
+ scorer = Scorer.new
54
+
55
+ candidates = []
56
+
57
+ hosts.each do |host|
58
+ log_debug("Scanning #{host[:ip]}")
59
+
60
+ # Port scan
61
+ ports = scanner.scan(host[:ip], ports: scan_ports)
62
+
63
+ candidate = host.merge(ports: ports)
64
+
65
+ # Check for WiFi AP match
66
+ candidate[:wifi_ap_match] = result[:wifi_aps].any? do |ap|
67
+ ap[:ssid]&.downcase&.include?("aim") ||
68
+ ap[:ssid]&.downcase&.include?("myc")
69
+ end
70
+
71
+ # HTTP probe if enabled and web port is open
72
+ if probe_http
73
+ web_ports = ports.select { |p, v| v[:open] && [80, 443, 8080].include?(p) }.keys
74
+ web_ports.each do |port|
75
+ probe_result = http_probe.probe(host[:ip], port: port)
76
+ candidate[:http_probe] = probe_result
77
+ break if probe_result[:reachable]
78
+ end
79
+ end
80
+
81
+ # Score the candidate
82
+ scored = scorer.score(candidate)
83
+ candidate = candidate.merge(
84
+ score: scored[:score],
85
+ reasons: scored[:reasons],
86
+ is_likely: scored[:is_likely]
87
+ )
88
+
89
+ open_ports = ports.select { |_, v| v[:open] }.keys
90
+ log_info("Scanned #{host[:ip]} (#{host[:mac]}): ports=#{open_ports}, score=#{scored[:score]}, reasons=#{scored[:reasons]}")
91
+
92
+ candidates << candidate
93
+ end
94
+
95
+ # Sort by score descending
96
+ result[:likely_candidates] = candidates
97
+ .select { |c| c[:score] > 0 }
98
+ .sort_by { |c| -c[:score] }
99
+ .map do |c|
100
+ {
101
+ ip: c[:ip],
102
+ mac: c[:mac],
103
+ score: c[:score],
104
+ reasons: c[:reasons],
105
+ is_likely: c[:is_likely],
106
+ ports: format_ports(c[:ports]),
107
+ http_probe: c[:http_probe]
108
+ }
109
+ end
110
+
111
+ log_info("Discovery complete: #{hosts.length} hosts, #{result[:likely_candidates].length} candidates")
112
+
113
+ result
114
+ end
115
+
116
+ # Quick scan for a specific IP
117
+ def probe_host(ip, scan_ports: nil)
118
+ scan_ports ||= @config.scan_ports
119
+
120
+ scanner = Network::Scanner.new(timeout: @config.scan_timeout)
121
+ http_probe = Network::HttpProbe.new(timeout: @config.http_timeout)
122
+ scorer = Scorer.new
123
+
124
+ ports = scanner.scan(ip, ports: scan_ports)
125
+
126
+ candidate = {
127
+ ip: ip,
128
+ mac: nil,
129
+ ports: ports
130
+ }
131
+
132
+ # HTTP probe
133
+ web_ports = ports.select { |p, v| v[:open] && [80, 443, 8080].include?(p) }.keys
134
+ web_ports.each do |port|
135
+ probe_result = http_probe.probe(ip, port: port)
136
+ candidate[:http_probe] = probe_result
137
+ break if probe_result[:reachable]
138
+ end
139
+
140
+ scored = scorer.score(candidate)
141
+ candidate.merge(
142
+ score: scored[:score],
143
+ reasons: scored[:reasons],
144
+ is_likely: scored[:is_likely],
145
+ ports: format_ports(ports)
146
+ )
147
+ end
148
+
149
+ private
150
+
151
+ def detect_network_info
152
+ info = Network::ARP.detect_subnet
153
+ return unless info
154
+
155
+ @interface = info[:interface]
156
+ @subnet = info[:subnet]
157
+ @local_ip = info[:local_ip]
158
+ end
159
+
160
+ def scan_wifi_aps
161
+ aps = []
162
+
163
+ if Network::ARP.macos?
164
+ aps = scan_wifi_macos
165
+ else
166
+ aps = scan_wifi_linux
167
+ end
168
+
169
+ aps.select { |ap| ap[:ssid]&.downcase&.match?(/aim|myc/) }
170
+ end
171
+
172
+ def scan_wifi_macos
173
+ airport_path = "/System/Library/PrivateFrameworks/Apple80211.framework/Versions/Current/Resources/airport"
174
+ return [] unless File.exist?(airport_path)
175
+
176
+ output, status = Open3.capture2(airport_path, "-s")
177
+ return [] unless status.success?
178
+
179
+ aps = []
180
+ output.each_line.with_index do |line, idx|
181
+ next if idx == 0 # Skip header
182
+
183
+ # Format: SSID BSSID RSSI CHANNEL HT CC SECURITY
184
+ parts = line.strip.split(/\s+/)
185
+ next if parts.length < 2
186
+
187
+ aps << { ssid: parts[0], bssid: parts[1] }
188
+ end
189
+ aps
190
+ end
191
+
192
+ def scan_wifi_linux
193
+ # Try nmcli first
194
+ output, status = Open3.capture2("nmcli", "-t", "-f", "SSID,BSSID", "device", "wifi", "list")
195
+ return [] unless status.success?
196
+
197
+ aps = []
198
+ output.each_line do |line|
199
+ parts = line.strip.split(":")
200
+ next if parts.length < 2
201
+
202
+ aps << { ssid: parts[0], bssid: parts[1..-1].join(":") }
203
+ end
204
+ aps
205
+ rescue Errno::ENOENT
206
+ # nmcli not available, try iwlist
207
+ scan_wifi_iwlist
208
+ end
209
+
210
+ def scan_wifi_iwlist
211
+ # Find wireless interface
212
+ interfaces = Dir["/sys/class/net/*/wireless"].map { |p| p.split("/")[-2] }
213
+ return [] if interfaces.empty?
214
+
215
+ iface = interfaces.first
216
+ output, status = Open3.capture2("sudo", "iwlist", iface, "scan")
217
+ return [] unless status.success?
218
+
219
+ aps = []
220
+ current_bssid = nil
221
+
222
+ output.each_line do |line|
223
+ if line.include?("Address:")
224
+ current_bssid = line.split("Address:").last.strip
225
+ elsif line.include?("ESSID:")
226
+ ssid = line.split("ESSID:").last.strip.tr('"', "")
227
+ aps << { ssid: ssid, bssid: current_bssid } if current_bssid
228
+ end
229
+ end
230
+ aps
231
+ rescue StandardError
232
+ []
233
+ end
234
+
235
+ def format_ports(ports)
236
+ ports.transform_values do |v|
237
+ { open: v[:open], banner: v[:banner] }
238
+ end.select { |_, v| v[:open] }
239
+ end
240
+ end
241
+ end
242
+ end
@@ -0,0 +1,123 @@
1
+ # frozen_string_literal: true
2
+
3
+ module MyChron
4
+ module Discovery
5
+ class Scorer
6
+ include Logging
7
+
8
+ # Scoring weights
9
+ WEIGHTS = {
10
+ wifi_ap_match: 50, # WiFi AP SSID matches AiM-MYC6-*
11
+ mychron_data_port: 40, # TCP port 2000 open (MyChron data protocol)
12
+ mac_oui_match: 30, # MAC vendor matches AiM OUI (confirmed)
13
+ espressif_mac: 20, # Espressif OUI (MyChron uses ESP32)
14
+ http_server: 20, # HTTP server responds
15
+ aim_banner: 10, # Banner contains AiM indicators
16
+ mdns_service: 5, # mDNS service discovered
17
+ common_port: 5, # Common service ports open (80, 8080)
18
+ unusual_port: 3 # Unusual ports open (suggests embedded device)
19
+ }.freeze
20
+
21
+ # Ports that suggest embedded device
22
+ EMBEDDED_PORTS = [5000, 6000, 7000, 9000] + (10_000..10_100).to_a
23
+
24
+ def initialize; end
25
+
26
+ # Score a candidate based on available evidence
27
+ # Returns { score:, reasons: [], is_likely: bool }
28
+ def score(candidate)
29
+ score = 0
30
+ reasons = []
31
+
32
+ # WiFi AP match (highest confidence)
33
+ if candidate[:wifi_ap_match]
34
+ score += WEIGHTS[:wifi_ap_match]
35
+ reasons << "wifi_ap_match"
36
+ end
37
+
38
+ # MAC OUI match (confirmed AiM)
39
+ if candidate[:is_aim]
40
+ score += WEIGHTS[:mac_oui_match]
41
+ reasons << "mac_oui_match"
42
+ end
43
+
44
+ # Espressif MAC (MyChron uses ESP32 WiFi)
45
+ if candidate[:is_espressif]
46
+ score += WEIGHTS[:espressif_mac]
47
+ reasons << "espressif_mac"
48
+ end
49
+
50
+ # HTTP probe results
51
+ if candidate[:http_probe]
52
+ http = candidate[:http_probe]
53
+
54
+ if http[:reachable]
55
+ score += WEIGHTS[:http_server]
56
+ reasons << "http_server"
57
+
58
+ if http[:aim_score] && http[:aim_score] > 0
59
+ score += http[:aim_score]
60
+ reasons << "aim_indicators:#{http[:aim_indicators].join(',')}"
61
+ end
62
+ end
63
+ end
64
+
65
+ # Port scan results
66
+ if candidate[:ports]
67
+ open_ports = candidate[:ports].select { |_, v| v[:open] }.keys
68
+
69
+ # MyChron TCP data port (strongest network signal)
70
+ if open_ports.include?(2000)
71
+ score += WEIGHTS[:mychron_data_port]
72
+ reasons << "mychron_data_port"
73
+ end
74
+
75
+ # Common web ports
76
+ if open_ports.intersect?([80, 443, 8080])
77
+ score += WEIGHTS[:common_port]
78
+ reasons << "common_ports"
79
+ end
80
+
81
+ # Embedded device ports
82
+ embedded_open = open_ports & EMBEDDED_PORTS
83
+ if embedded_open.any?
84
+ score += WEIGHTS[:unusual_port] * [embedded_open.length, 5].min
85
+ reasons << "embedded_ports:#{embedded_open.join(',')}"
86
+ end
87
+
88
+ # Check banners for AiM indicators
89
+ candidate[:ports].each do |port, info|
90
+ next unless info[:open] && info[:banner]
91
+
92
+ banner_lower = info[:banner].downcase
93
+ if %w[aim mychron racestudio].any? { |ind| banner_lower.include?(ind) }
94
+ score += WEIGHTS[:aim_banner]
95
+ reasons << "banner_match:#{port}"
96
+ end
97
+ end
98
+ end
99
+
100
+ {
101
+ score: score,
102
+ reasons: reasons,
103
+ is_likely: score >= 30 # Threshold for "likely" candidate
104
+ }
105
+ end
106
+
107
+ # Compare two candidates, return the better one
108
+ def compare(a, b)
109
+ score_a = score(a)[:score]
110
+ score_b = score(b)[:score]
111
+ score_a >= score_b ? a : b
112
+ end
113
+
114
+ # Sort candidates by score descending
115
+ def rank(candidates)
116
+ candidates.map do |c|
117
+ scored = score(c)
118
+ c.merge(score: scored[:score], reasons: scored[:reasons], is_likely: scored[:is_likely])
119
+ end.sort_by { |c| -c[:score] }
120
+ end
121
+ end
122
+ end
123
+ end
@@ -0,0 +1,27 @@
1
+ # frozen_string_literal: true
2
+
3
+ module MyChron
4
+ # Base error class for all MyChron errors
5
+ class Error < StandardError; end
6
+
7
+ # Raised when unable to connect to the device
8
+ class ConnectionError < Error; end
9
+
10
+ # Raised when the protocol communication fails
11
+ class ProtocolError < Error; end
12
+
13
+ # Raised when an operation times out
14
+ class TimeoutError < Error; end
15
+
16
+ # Raised when no sessions are found on the device
17
+ class NoSessionsError < Error; end
18
+
19
+ # Raised when a file download fails
20
+ class DownloadError < Error; end
21
+
22
+ # Raised when device discovery fails
23
+ class DiscoveryError < Error; end
24
+
25
+ # Raised when the device is not reachable
26
+ class DeviceNotFoundError < Error; end
27
+ end
@@ -0,0 +1,79 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "logger"
4
+
5
+ module MyChron
6
+ # Logging module that integrates with Rails.logger when available
7
+ # Falls back to a null logger for silent library operation
8
+ module Logging
9
+ class << self
10
+ # Get the current logger instance
11
+ # Auto-detects Rails.logger if available
12
+ def logger
13
+ @logger ||= detect_logger
14
+ end
15
+
16
+ # Set a custom logger
17
+ # @param logger [Logger] A Logger-compatible instance
18
+ def logger=(logger)
19
+ @logger = logger
20
+ end
21
+
22
+ # Reset to auto-detection
23
+ def reset!
24
+ @logger = nil
25
+ end
26
+
27
+ private
28
+
29
+ def detect_logger
30
+ if defined?(Rails) && Rails.respond_to?(:logger) && Rails.logger
31
+ Rails.logger
32
+ else
33
+ # Silent by default for library use
34
+ # Users can set MyChron::Logging.logger = Logger.new(STDOUT) if needed
35
+ null_logger
36
+ end
37
+ end
38
+
39
+ def null_logger
40
+ Logger.new(IO::NULL)
41
+ end
42
+ end
43
+
44
+ # Instance method to access logger from including classes
45
+ def logger
46
+ MyChron::Logging.logger
47
+ end
48
+
49
+ # Log at debug level
50
+ def log_debug(message, context = {})
51
+ logger.debug { format_message(message, context) }
52
+ end
53
+
54
+ # Log at info level
55
+ def log_info(message, context = {})
56
+ logger.info { format_message(message, context) }
57
+ end
58
+
59
+ # Log at warn level
60
+ def log_warn(message, context = {})
61
+ logger.warn { format_message(message, context) }
62
+ end
63
+
64
+ # Log at error level
65
+ def log_error(message, context = {})
66
+ logger.error { format_message(message, context) }
67
+ end
68
+
69
+ private
70
+
71
+ def format_message(message, context)
72
+ if context.empty?
73
+ "[MyChron] #{message}"
74
+ else
75
+ "[MyChron] #{message} #{context.inspect}"
76
+ end
77
+ end
78
+ end
79
+ end
@@ -0,0 +1,165 @@
1
+ # frozen_string_literal: true
2
+
3
+ module MyChron
4
+ module Monitor
5
+ class Watcher
6
+ include Logging
7
+
8
+ attr_reader :subnet, :interface, :local_ip
9
+ attr_reader :known_candidates, :best_candidate
10
+
11
+ def initialize(subnet: nil, interface: nil)
12
+ @subnet = subnet
13
+ @interface = interface
14
+ @local_ip = nil
15
+ @config = MyChron.config
16
+ @running = false
17
+ @known_candidates = {}
18
+ @best_candidate = nil
19
+
20
+ detect_network_info unless @subnet
21
+ end
22
+
23
+ # Start watching loop
24
+ def start(interval: nil)
25
+ interval ||= @config.watch_interval
26
+ @running = true
27
+
28
+ log_info("Watch started on #{@subnet} (#{@interface}), interval: #{interval}s")
29
+
30
+ trap("INT") { stop }
31
+ trap("TERM") { stop }
32
+
33
+ detector = Discovery::Detector.new(interface: @interface, subnet: @subnet)
34
+
35
+ while @running
36
+ begin
37
+ check_cycle(detector)
38
+ rescue StandardError => e
39
+ log_error("Watch error: #{e.message}")
40
+ end
41
+
42
+ sleep(interval) if @running
43
+ end
44
+
45
+ log_info("Watch stopped")
46
+ end
47
+
48
+ # Stop watching
49
+ def stop
50
+ @running = false
51
+ end
52
+
53
+ # One-shot probe of specific IP
54
+ def probe(ip)
55
+ detector = Discovery::Detector.new(interface: @interface, subnet: @subnet)
56
+ result = detector.probe_host(ip)
57
+
58
+ log_info("Probe #{ip}: score=#{result[:score]}, likely=#{result[:is_likely]}, ports=#{result[:ports].keys}")
59
+
60
+ result
61
+ end
62
+
63
+ # Attempt download handshake (dry-run)
64
+ def download_attempt(ip, dry_run: true)
65
+ log_info("Download attempt for #{ip} (dry_run=#{dry_run})")
66
+
67
+ # First, probe the host
68
+ result = probe(ip)
69
+
70
+ unless result[:is_likely]
71
+ log_warn("#{ip} is unlikely candidate (score=#{result[:score]})")
72
+ return { success: false, reason: "unlikely_candidate", probe: result }
73
+ end
74
+
75
+ # Try to identify protocol
76
+ protocol = identify_protocol(ip, result)
77
+
78
+ log_info("Protocol identified for #{ip}: #{protocol}")
79
+
80
+ if dry_run
81
+ return {
82
+ success: true,
83
+ dry_run: true,
84
+ protocol: protocol,
85
+ probe: result,
86
+ message: "Dry run complete. Would attempt #{protocol} download."
87
+ }
88
+ end
89
+
90
+ # Actual download would go here
91
+ { success: false, reason: "download_not_implemented", protocol: protocol }
92
+ end
93
+
94
+ private
95
+
96
+ def detect_network_info
97
+ info = Network::ARP.detect_subnet
98
+ return unless info
99
+
100
+ @interface = info[:interface]
101
+ @subnet = info[:subnet]
102
+ @local_ip = info[:local_ip]
103
+ end
104
+
105
+ def check_cycle(detector)
106
+ log_debug("Check cycle started")
107
+
108
+ # Quick ARP check
109
+ arp = Network::ARP.new(interface: @interface)
110
+ hosts = arp.discover
111
+
112
+ current_ips = hosts.map { |h| h[:ip] }
113
+ known_ips = @known_candidates.keys
114
+
115
+ # Detect new hosts
116
+ new_ips = current_ips - known_ips
117
+ lost_ips = known_ips - current_ips
118
+
119
+ # Log lost candidates
120
+ lost_ips.each do |ip|
121
+ candidate = @known_candidates.delete(ip)
122
+ if candidate && candidate[:is_likely]
123
+ log_info("Candidate lost: #{ip} (mac=#{candidate[:mac]}, score=#{candidate[:score]})")
124
+ end
125
+ end
126
+
127
+ # Probe new hosts
128
+ new_ips.each do |ip|
129
+ host = hosts.find { |h| h[:ip] == ip }
130
+ next unless host
131
+
132
+ result = detector.probe_host(ip, scan_ports: quick_ports)
133
+
134
+ @known_candidates[ip] = result.merge(mac: host[:mac])
135
+
136
+ if result[:is_likely]
137
+ log_info("Candidate detected: #{ip} (mac=#{host[:mac]}, score=#{result[:score]}, reasons=#{result[:reasons]})")
138
+
139
+ # Update best candidate
140
+ if @best_candidate.nil? || result[:score] > @best_candidate[:score]
141
+ @best_candidate = result.merge(ip: ip, mac: host[:mac])
142
+ end
143
+ end
144
+ end
145
+
146
+ log_debug("Check cycle complete: #{hosts.length} hosts, #{new_ips.length} new, #{lost_ips.length} lost")
147
+ end
148
+
149
+ def quick_ports
150
+ # Smaller port set for quick checks
151
+ [21, 22, 80, 443, 8080, 5000, 7000, 9000, 10000, 10080]
152
+ end
153
+
154
+ def identify_protocol(ip, probe_result)
155
+ open_ports = probe_result[:ports].keys
156
+
157
+ return "ftp" if open_ports.include?(21)
158
+ return "http" if open_ports.intersect?([80, 8080])
159
+ return "https" if open_ports.include?(443)
160
+
161
+ "unknown"
162
+ end
163
+ end
164
+ end
165
+ end