wireless-rb 0.1.0 → 0.2.0

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 (43) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +49 -0
  3. data/README-zh.md +37 -2
  4. data/README.md +34 -3
  5. data/docs/architecture-zh.md +4 -4
  6. data/lib/wireless/ap_session.rb +113 -52
  7. data/lib/wireless/collector.rb +49 -36
  8. data/lib/wireless/configuration.rb +0 -2
  9. data/lib/wireless/coverage.rb +15 -5
  10. data/lib/wireless/fields.rb +25 -20
  11. data/lib/wireless/h3c/{authentication_parser.rb → authentication.rb} +4 -4
  12. data/lib/wireless/h3c/command_output.rb +44 -3
  13. data/lib/wireless/h3c/configuration.rb +21 -5
  14. data/lib/wireless/h3c/document.rb +32 -0
  15. data/lib/wireless/h3c/parsers/display_current_configuration.rb +143 -69
  16. data/lib/wireless/h3c/parsers/display_dot1x_connection.rb +4 -2
  17. data/lib/wireless/h3c/parsers/display_lldp_neighbor_information_verbose.rb +34 -10
  18. data/lib/wireless/h3c/parsers/display_mac_authentication_connection.rb +4 -2
  19. data/lib/wireless/h3c/parsers/display_wlan_ap_all_verbose.rb +37 -11
  20. data/lib/wireless/h3c/parsers/display_wlan_ap_model_all.rb +5 -5
  21. data/lib/wireless/h3c/parsers/display_wlan_bss_all_verbose.rb +6 -6
  22. data/lib/wireless/h3c/parsers/display_wlan_client_security_block_mac.rb +2 -2
  23. data/lib/wireless/h3c/parsers/display_wlan_client_verbose.rb +14 -8
  24. data/lib/wireless/h3c/parsers/display_wlan_statistics_client_ip_conflict.rb +2 -2
  25. data/lib/wireless/h3c/profile.rb +6 -3
  26. data/lib/wireless/h3c/record_parser.rb +138 -0
  27. data/lib/wireless/huawei/parser.rb +86 -0
  28. data/lib/wireless/huawei/parsers/display_ap_all.rb +14 -0
  29. data/lib/wireless/huawei/parsers/display_station_all.rb +14 -0
  30. data/lib/wireless/huawei/profile.rb +22 -0
  31. data/lib/wireless/observation.rb +2 -0
  32. data/lib/wireless/parser_registry.rb +2 -1
  33. data/lib/wireless/projection.rb +4 -0
  34. data/lib/wireless/runtime_parser.rb +16 -11
  35. data/lib/wireless/snapshot.rb +2 -1
  36. data/lib/wireless/terminal.rb +32 -0
  37. data/lib/wireless/version.rb +1 -1
  38. data/lib/wireless.rb +0 -2
  39. metadata +9 -20
  40. data/lib/wireless/h3c/session_parser.rb +0 -58
  41. data/lib/wireless/h3c/verbose_parser.rb +0 -82
  42. data/lib/wireless/h3c_runtime.rb +0 -18
  43. data/lib/wireless/huawei_runtime.rb +0 -65
@@ -16,12 +16,12 @@ module Wireless
16
16
  row = line.strip
17
17
  next if row.empty? || /\A[-=]+\z/.match?(row) || /\A(?:No blocked clients|MAC address\b.*|(?:Client )?block list|Blocked clients|Total.*:\s*\d+)\z/i.match?(row)
18
18
  if (match = /\A((?:[0-9a-f]{4}-){2}[0-9a-f]{4}|(?:[0-9a-f]{2}:){5}[0-9a-f]{2})\s*(.*?)\z/i.match(row))
19
- records << { mac_address: Fields.mac(match[1]), reason: match[2] }
19
+ records << { mac_address: Fields.mac_address(match[1]), reason: match[2] }
20
20
  else
21
21
  issues << { code: :unrecognized_line, line: index + 1 }
22
22
  end
23
23
  end
24
- total = /Total[^\n:]*:\s*(\d+)/i.match(text)&.[](1)&.to_i
24
+ total = reported_total(text, noun: "(?:blocked clients|clients)", issues: issues)
25
25
  total = 0 if /\ANo blocked clients\s*$/i.match?(text.strip)
26
26
  Fields.result(records, issues: issues, complete: complete, total: total, identity: [ :mac_address ])
27
27
  end
@@ -1,17 +1,17 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require_relative "../session_parser"
3
+ require_relative "../record_parser"
4
4
 
5
5
  module Wireless
6
6
  module H3c
7
7
  module Parsers
8
8
  # 解析客户端会话,区分相对 RSSI 与实测 dBm。
9
- class DisplayWlanClientVerbose < SessionParser
9
+ class DisplayWlanClientVerbose < RecordParser
10
10
  MAPPING = COMMON_FIELDS.merge("mac address" => :client_mac, "ipv4 address" => :client_ip,
11
11
  "ip address" => :client_ip, "ipv6 address" => :client_ipv6, "username" => :username,
12
12
  "channel" => :channel, "frequency band" => :band, "state" => :operational_status, "status" => :operational_status,
13
13
  "wireless mode" => :protocol, "channel bandwidth" => :channel_width, "sm power save mode" => :power_save,
14
- "sm power save" => :power_save, "qos mode" => :qos, "online time" => :uptime_seconds,
14
+ "sm power save" => :power_save, "qos mode" => :qos, "online time" => :online_seconds,
15
15
  "authentication method" => :auth_method, "security mode" => :security, "akm mode" => :akm,
16
16
  "cipher suite" => :cipher).freeze
17
17
  DETAILS = [ "authorization car", "authorization acl id", "authorization user profile", "key derivation",
@@ -29,15 +29,21 @@ module Wireless
29
29
  private
30
30
 
31
31
  # 归一化成对客户端速率,区分相对 RSSI 与实测 dBm。
32
- def assign_session_field(record, label, raw, issues, line)
32
+ def assign_record_field(record, label, raw, issues, line)
33
33
  case label
34
34
  when "rx/tx rate"
35
- safely(issues, line) { record[:rx_rate_mbps], record[:tx_rate_mbps] = Fields.rates(raw) }
35
+ parse_field(issues, line) do
36
+ rx, tx = Fields.rates_mbps(raw)
37
+ assign_value(record, :rx_rate_mbps, rx, issues, line)
38
+ assign_value(record, :tx_rate_mbps, tx, issues, line)
39
+ end
36
40
  when "rssi"
37
- safely(issues, line) do
41
+ parse_field(issues, line) do
38
42
  signal = Fields.number(raw, unit: "dBm")
39
- record[:signal_dbm] = signal if signal&.negative?
40
- record[:rssi] = signal.round if signal&.positive?
43
+ assign_value(record, :signal_dbm, signal&.negative? ? signal : nil, issues, line)
44
+ assign_value(record, :rssi, signal&.positive? ? signal.round : nil, issues, line)
45
+ record.delete(:signal_dbm) if record[:signal_dbm].nil?
46
+ record.delete(:rssi) if record[:rssi].nil?
41
47
  end
42
48
  else
43
49
  super
@@ -41,8 +41,8 @@ module Wireless
41
41
  month, day, hour, minute, second = match[6].split(/[- :]/).map(&:to_i)
42
42
  raise ArgumentError, "Invalid conflict time" unless Date.valid_date?(2000, month, day) && hour < 24 && minute < 60 && second < 60
43
43
 
44
- { ip_address: Fields.ip(match[1]), new_mac_address: Fields.mac(match[2]), new_ap_id: Fields.integer(match[3]).to_s,
45
- old_mac_address: Fields.mac(match[4]), old_ap_id: Fields.integer(match[5]).to_s, occurred_at: match[6] }
44
+ { ip_address: Fields.ip_address(match[1]), new_mac_address: Fields.mac_address(match[2]), new_ap_id: Fields.integer(match[3]).to_s,
45
+ old_mac_address: Fields.mac_address(match[4]), old_ap_id: Fields.integer(match[5]).to_s, occurred_at: match[6] }
46
46
  end
47
47
  end
48
48
  end
@@ -16,7 +16,7 @@ module Wireless
16
16
  module H3c
17
17
  # 声明 H3C 控制器及 AP 策略,共享回显仅解析一次后再投影维度。
18
18
  class Profile < CtxParser::Profile
19
- CATALOG = CtxParser::CommandCatalog.new([
19
+ DECLARATIONS = CtxParser::Commands.new([
20
20
  { dimensions: [ :client_ip_conflicts ], command: "display wlan statistics client-ip-conflict", parser: Parsers::DisplayWlanStatisticsClientIpConflict },
21
21
  { dimensions: [ :ap_models ], command: "display wlan ap-model all", parser: Parsers::DisplayWlanApModelAll },
22
22
  { dimensions: %i[access_points radios], command: "display wlan ap all verbose", parser: Parsers::DisplayWlanApAllVerbose },
@@ -28,8 +28,11 @@ module Wireless
28
28
  { dimensions: [ :blocked_clients ], command: "display wlan client-security block-mac", parser: Parsers::DisplayWlanClientSecurityBlockMac },
29
29
  { dimensions: [ :lldp ], command: "display lldp neighbor-information verbose", parser: Parsers::DisplayLldpNeighborInformationVerbose }
30
30
  ])
31
- COMMANDS = CATALOG.commands
32
- PARSERS = CATALOG.parsers
31
+ COMMANDS = DECLARATIONS.by_dimension
32
+ PARSERS = DECLARATIONS.parsers
33
+
34
+ # 本平台在当前控制器会话禁用分页。
35
+ def preparation_command = "screen-length disable"
33
36
 
34
37
  private
35
38
 
@@ -0,0 +1,138 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "parser"
4
+
5
+ module Wireless
6
+ module H3c
7
+ # 按字段标签划分 H3C 详细记录,并共享字段转换与诊断。
8
+ class RecordParser < Parser
9
+ COMMON_FIELDS = { "ap name" => :ap_name, "ap id" => :ap_id, "radio id" => :radio_id,
10
+ "ssid" => :ssid, "bssid" => :bssid, "vlan id" => :vlan, "service vlan id" => :vlan }.freeze
11
+ DETAILS = [].freeze
12
+
13
+ private
14
+
15
+ # 各命令声明记录身份与字段映射,嵌套记录可覆盖此入口。
16
+ def parse_text(text, complete:)
17
+ issues, records = [], []
18
+ fields = read_fields(text, issues)
19
+ start_label = record_start_label(fields)
20
+ current = nil
21
+ fields.each do |label, raw, line|
22
+ if start_label ? label == start_label : self.class::MAPPING[label] == self.class::IDENTITY.first
23
+ current = {}
24
+ records << current
25
+ end
26
+ unless current
27
+ issues << { code: :missing_identity, line: line }
28
+ next
29
+ end
30
+ assign_record_field(current, label, raw, issues, line)
31
+ end
32
+ records.reject! do |record|
33
+ invalid = !valid_identity?(record)
34
+ issues << { code: :invalid_identity } if invalid
35
+ invalid
36
+ end
37
+ Fields.result(records, issues: issues, complete: complete, total: record_count(text, self.class::COUNT, issues), identity: self.class::IDENTITY)
38
+ end
39
+
40
+ # 返回字段标签、原始值与来源行号,保留明确允许的续行。
41
+ def read_fields(text, issues)
42
+ text.lines.each_with_index.each_with_object([]) do |(line, index), fields|
43
+ row = line.strip
44
+ next if row.empty? || /\A[-=]+\z/.match?(row)
45
+ if (match = /\A([^:]+?)\s*:\s*(.*)\z/.match(row))
46
+ label = match[1].downcase.gsub(/\s+/, " ")
47
+ next if /\A(?:total|number of aps|connected|remaining|maximum|current number|(?:local|server|sync) ap licenses|(?:current|max|bound) bss number)\b/.match?(label)
48
+ fields << [ label, Fields.value(match[2]), index + 1, line[/\A\s*/].size ]
49
+ elsif (match = /\A:\s*(\S.*)\z/.match(row)) && fields.last&.first == "poe status"
50
+ fields.last[1] = [ fields.last[1], match[1] ].compact.join("\n")
51
+ elsif /\A(?:NSS\d+\s+[\d, ]+|TID \d+\s+(?:In|Out|Both)|[\d., ]+(?:Mbps)?)\z/.match?(row)
52
+ next
53
+ else
54
+ issues << { code: :unrecognized_line, line: index + 1 }
55
+ end
56
+ end
57
+ end
58
+
59
+ # 更新当前记录前,先应用字段专用转换。
60
+ def assign_field(record, mapping, label, raw, issues, line, details:)
61
+ if record && (field = mapping[label])
62
+ parse_field(issues, line) do
63
+ # 优先使用运行带宽;80+80MHz 等歧义值保持原义,不截断为单个数值。
64
+ if field == :channel_width
65
+ label = "channel bandwidth" if label == "channel band-width"
66
+ assign_value(record[:details] ||= {}, label.tr(" ", "_").to_sym, raw, issues, line)
67
+ return if label == "channel bandwidth" && @field_assignments.fetch(record[:details]).key?(:operating_bandwidth)
68
+ end
69
+ assign_value(record, field, convert_field(field, raw, label: label), issues, line, identity: field == :channel_width ? label : field)
70
+ end
71
+ elsif details
72
+ value = label.end_with?("url") ? !raw.nil? && !raw.strip.empty? : raw
73
+ assign_value(details[:details] ||= {}, label.tr(" -", "__").to_sym, value, issues, line)
74
+ else
75
+ issues << { code: :unrecognized_field, line: line }
76
+ end
77
+ end
78
+
79
+ # 按声明的归一化规则转换已知字段。
80
+ def convert_field(field, raw, label:)
81
+ case field
82
+ when :mac_address, :client_mac, :bssid then Fields.mac_address(raw)
83
+ when :management_ip, :client_ip, :client_ipv6, :ip_address, :ipv6_address
84
+ address = Fields.ip_address(raw)
85
+ if address && ((label == "ipv4 address" && address.include?(":")) || (label == "ipv6 address" && !address.include?(":")))
86
+ raise ArgumentError, "Invalid wireless address family"
87
+ end
88
+ address
89
+ when :ap_id, :radio_id then Fields.integer(raw)&.to_s
90
+ when :client_count, :online_seconds, :uptime_seconds
91
+ %i[online_seconds uptime_seconds].include?(field) ? Fields.duration_seconds(raw) : Fields.integer(raw)
92
+ when :vlan, :initial_vlan_id, :authorized_vlan_id
93
+ vlan = Fields.integer(raw, max: 4094)
94
+ raise ArgumentError, "Invalid wireless VLAN" if vlan && vlan.zero?
95
+ vlan
96
+ when :channel then Fields.channel(raw)
97
+ when :channel_width then Fields.channel_width_mhz(raw)
98
+ when :band then Fields.frequency_band(raw)
99
+ when :noise_dbm then Fields.number(raw, unit: "dBm")&.then { |number| number if number.negative? }
100
+ when :utilization
101
+ number = Fields.number(raw, unit: "%")
102
+ raise ArgumentError, "Invalid wireless utilization" if number && !(0..100).cover?(number)
103
+ number&.round
104
+ else raw
105
+ end
106
+ end
107
+
108
+ # 解析非负计数,同时保留未知值。
109
+ def record_count(text, noun, issues)
110
+ reported_total(text, noun: noun, issues: issues)
111
+ end
112
+
113
+ # 隔离单个格式错误字段,同时保留无关记录数据。
114
+ def parse_field(issues, line)
115
+ yield
116
+ rescue ArgumentError, IPAddr::InvalidAddressError
117
+ issues << { code: :invalid_field, line: line }
118
+ end
119
+
120
+ # 默认以身份字段开始新记录,命令可声明其他起始标签。
121
+ def record_start_label(fields)
122
+ nil
123
+ end
124
+
125
+ # 要求具备关联当前记录所需的稳定身份字段。
126
+ def valid_identity?(record)
127
+ value = record[self.class::IDENTITY.first]
128
+ value && !value.strip.empty?
129
+ end
130
+
131
+ # 将命令字段归一化到当前记录,并保留显式允许的详情。
132
+ def assign_record_field(record, label, raw, issues, line)
133
+ details = self.class::DETAILS.include?(label) ? record : nil
134
+ assign_field(record, self.class::MAPPING, label, raw, issues, line, details: details)
135
+ end
136
+ end
137
+ end
138
+ end
@@ -0,0 +1,86 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../fields"
4
+ require_relative "../terminal"
5
+ require "ctx_parser/parser"
6
+
7
+ module Wireless
8
+ module Huawei
9
+ # 仅支持有证据的 Huawei 表格格式,区分不支持维度与空清单。
10
+ class Parser < CtxParser::Parser
11
+ private
12
+
13
+ # 标准解析边界负责参数校验,子类只选择对应命令的维度。
14
+ def parse_output(text, complete:)
15
+ name = self.class::DIMENSION
16
+ text = Fields.text(text)
17
+ if Terminal.unsupported?(text)
18
+ return CtxParser::Result.new(status: :unsupported, issues: [ { code: :unsupported_command } ])
19
+ end
20
+ if (issue = Terminal.issue(text))
21
+ return CtxParser::Result.new(status: :partial, issues: [ issue ])
22
+ end
23
+ records, issues = [], []
24
+ header = false
25
+ total = nil
26
+ text.lines.each_with_index do |line, index|
27
+ row = line.strip
28
+ next if row.empty? || /\A[-=]+\z/.match?(row)
29
+ if (match = /\ATotal\s*:\s*(\d+)\z/i.match(row))
30
+ reported = match[1].to_i
31
+ issues << { code: :conflicting_total, line: index + 1 } if total && total != reported
32
+ total ||= reported
33
+ elsif name == :access_points ? /\AID\s+MAC\s+Name\s+Group\s+IP\s+Type\s+State\s+STA\s+Uptime\b/i.match?(row) : /\ASTA MAC\s+AP ID\s+Ap name\s+Rf\/WLAN\b/i.match?(row)
34
+ header = true
35
+ elsif /\ATotal AP information:|\A(?:nor|fault|idle|stdby)\s*:/i.match?(row)
36
+ next
37
+ elsif !header
38
+ issues << { code: :missing_header, line: index + 1 }
39
+ else
40
+ begin
41
+ records << (name == :access_points ? ap(row.split) : client(row.split))
42
+ rescue ArgumentError, IPAddr::InvalidAddressError
43
+ issues << { code: :invalid_row, line: index + 1 }
44
+ end
45
+ end
46
+ end
47
+ issues << { code: :missing_header } unless header
48
+ result = Fields.result(records, issues: issues, complete: complete, total: total, identity: [ name == :access_points ? :name : :client_mac ])
49
+ result
50
+ rescue ArgumentError, IPAddr::InvalidAddressError
51
+ CtxParser::Result.new(status: :failed, issues: [ { code: :invalid_output } ])
52
+ end
53
+
54
+ private
55
+
56
+ # IPv6 专用列不能接受合法但属于 IPv4 的地址。
57
+ def ipv6_address(raw)
58
+ address = Fields.ip_address(raw)
59
+ raise ArgumentError, "Invalid station IPv6 address" if address && !address.include?(":")
60
+ address
61
+ end
62
+
63
+ # 归一化控制器报告的单条 AP 记录。
64
+ def ap(columns)
65
+ raise ArgumentError, "Invalid AP row" unless columns.size == 9
66
+ id, mac, name, group, ip, model, state, clients, uptime = columns
67
+ { ap_id: Fields.integer(id).to_s, mac_address: Fields.mac_address(mac), name: name, group_name: group,
68
+ management_ip: Fields.ip_address(Fields.value(ip)), model: model, operational_status: Fields.online_status(state),
69
+ client_count: Fields.integer(clients), uptime_seconds: Fields.duration_seconds(Fields.value(uptime)) }
70
+ end
71
+
72
+ # 归一化控制器报告的单条无线客户端记录。
73
+ def client(columns)
74
+ raise ArgumentError, "Invalid station row" unless columns.size == 15
75
+ mac, ap_id, ap_name, radio_wlan, band, protocol, rates, rssi, vlan, ip, ssid, state, ipv6, _ac_id, uptime = columns
76
+ raise ArgumentError, "Invalid station identity" unless /\A\d+\/\d+\z/.match?(radio_wlan)
77
+ rx, tx = Fields.rates_mbps(rates)
78
+ signal = Fields.number(Fields.value(rssi), unit: "dBm")
79
+ { client_mac: Fields.mac_address(mac), ap_id: Fields.integer(ap_id).to_s, ap_name: ap_name, radio_id: radio_wlan.split("/").first,
80
+ band: Fields.frequency_band(band), protocol: protocol, rx_rate_mbps: rx, tx_rate_mbps: tx, signal_dbm: signal == 0 ? nil : signal,
81
+ vlan: Fields.integer(vlan, max: 4094), client_ip: Fields.ip_address(Fields.value(ip)), ssid: ssid, operational_status: state,
82
+ client_ipv6: ipv6_address(Fields.value(ipv6)), uptime_seconds: Fields.duration_seconds(Fields.value(uptime)) }
83
+ end
84
+ end
85
+ end
86
+ end
@@ -0,0 +1,14 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../parser"
4
+
5
+ module Wireless
6
+ module Huawei
7
+ module Parsers
8
+ # 解析对应 Huawei 无线查询的表格事实。
9
+ class DisplayApAll < Parser
10
+ DIMENSION = :access_points
11
+ end
12
+ end
13
+ end
14
+ end
@@ -0,0 +1,14 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../parser"
4
+
5
+ module Wireless
6
+ module Huawei
7
+ module Parsers
8
+ # 解析对应 Huawei 无线查询的表格事实。
9
+ class DisplayStationAll < Parser
10
+ DIMENSION = :clients
11
+ end
12
+ end
13
+ end
14
+ end
@@ -0,0 +1,22 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "ctx_parser/profile"
4
+ require_relative "parsers/display_ap_all"
5
+ require_relative "parsers/display_station_all"
6
+
7
+ module Wireless
8
+ module Huawei
9
+ # Huawei 查询通过相同命令声明和参数协议进入采集与解析。
10
+ class Profile < CtxParser::Profile
11
+ DECLARATIONS = CtxParser::Commands.new([
12
+ { dimensions: [ :access_points ], command: "display ap all", parser: Parsers::DisplayApAll },
13
+ { dimensions: [ :clients ], command: "display station all", parser: Parsers::DisplayStationAll }
14
+ ])
15
+ COMMANDS = DECLARATIONS.by_dimension
16
+ PARSERS = DECLARATIONS.parsers
17
+
18
+ # 本平台使用临时会话分页设置。
19
+ def preparation_command = "screen-length 0 temporary"
20
+ end
21
+ end
22
+ end
@@ -7,6 +7,7 @@ module Wireless
7
7
  Observation = Data.define(:name, :status, :records, :issues, :evidence, :started_at, :completed_at) do
8
8
  # 冻结单个维度的记录、状态及诊断元数据。
9
9
  def initialize(name:, status:, records: [], issues: [], evidence: [], started_at: nil, completed_at: nil)
10
+ raise ArgumentError, "Wireless observation name must be a symbol" unless name.is_a?(Symbol)
10
11
  raise ArgumentError, "Invalid wireless observation status" unless %i[complete partial failed unsupported not_requested].include?(status)
11
12
  raise ArgumentError, "Wireless records must be hashes" unless records.is_a?(Array) && records.all? { |record| record.is_a?(Hash) }
12
13
  raise ArgumentError, "Unavailable wireless observation has records" if %i[failed unsupported not_requested].include?(status) && records.any?
@@ -24,5 +25,6 @@ module Wireless
24
25
  def available? = complete? || partial?
25
26
  # 展示对象身份与状态,避免泄露原始回显或凭据。
26
27
  def inspect = "#<#{self.class.name} name=#{name} status=#{status} records=#{records.size}>"
28
+ alias to_s inspect
27
29
  end
28
30
  end
@@ -2,8 +2,9 @@
2
2
 
3
3
  require "ctx_parser/registry"
4
4
  require_relative "h3c/profile"
5
+ require_relative "huawei/profile"
5
6
 
6
7
  module Wireless
7
8
  # 将 h3c_wireless 视为 H3C 无线平台策略的连接器别名。
8
- ParserRegistry = CtxParser::Registry.new(profiles: { h3c: H3c::Profile }, aliases: { h3c_wireless: :h3c })
9
+ ParserRegistry = CtxParser::Registry.new(profiles: { h3c: H3c::Profile, huawei: Huawei::Profile }, aliases: { h3c_wireless: :h3c })
9
10
  end
@@ -12,5 +12,9 @@ module Wireless
12
12
 
13
13
  # 判断所有请求的资产投影是否均已完成。
14
14
  def complete? = dimensions.values.all? { |entry| entry.fetch(:status) == :complete }
15
+
16
+ # 展示应用范围,不在日志字符串中序列化应用详情。
17
+ def inspect = "#<#{self.class.name} dimensions=#{dimensions.size}>"
18
+ alias to_s inspect
15
19
  end
16
20
  end
@@ -2,15 +2,16 @@
2
2
 
3
3
  require_relative "parser_registry"
4
4
  require_relative "fields"
5
- require_relative "huawei_runtime"
6
5
  require_relative "observation"
7
6
 
8
7
  module Wireless
9
- # 将平台解析结果映射为无线维度,同时保留 Huawei 解析入口。
8
+ # 按平台解析无线观测,统一保留各维度的完整性与诊断。
10
9
  class RuntimeParser
11
10
  # 保留型号选择信息,供每次解析所选平台使用。
12
11
  def initialize(model: nil)
13
- @model = model
12
+ raise ArgumentError, "Device model must be text" unless model.nil? || model.is_a?(String)
13
+
14
+ @model = model&.dup&.freeze
14
15
  end
15
16
 
16
17
  # 使用传入平台及保留的型号选择解析单个无线维度。
@@ -20,21 +21,25 @@ module Wireless
20
21
 
21
22
  # 共享命令仅解析一次,再投影各请求维度。
22
23
  def parse_many(names, content, platform: "h3c", complete: false)
23
- text = Fields.text(content)
24
- if H3c::Parser::UNSUPPORTED.match?(text)
25
- return unavailable(names, :unsupported, :unsupported_command)
26
- end
27
- if platform.to_s == "huawei"
28
- return names.to_h { |name| [ name, HuaweiRuntime.new.parse(name, text, complete: complete) ] }
24
+ unless names.is_a?(Array) && names.any? && names.all? { |name| name.is_a?(Symbol) } && names.uniq == names
25
+ raise ArgumentError, "Dimensions must be unique symbols"
29
26
  end
27
+ raise ArgumentError, "Completeness must be boolean" unless [ true, false ].include?(complete)
28
+ raise ArgumentError, "Parser input must be text" unless content.is_a?(String)
30
29
 
31
30
  profile = ParserRegistry.fetch(platform, model: @model)
31
+ begin
32
+ text = Fields.text(content, newlines: :preserve)
33
+ rescue ArgumentError
34
+ return unavailable(names, :failed, :invalid_output)
35
+ end
36
+ if Terminal.unsupported?(text)
37
+ return unavailable(names, :unsupported, :unsupported_command)
38
+ end
32
39
  supported, unsupported = names.partition { |name| profile.commands.key?(name) }
33
40
  results = supported.empty? ? {} : profile.parse_many(supported, text, complete: complete)
34
41
  observations = results.to_h { |name, result| [ name, Observation.new(name: name, **result.to_h) ] }
35
42
  observations.merge(unavailable(unsupported, :unsupported, :unsupported_platform_dimension))
36
- rescue ArgumentError, IPAddr::InvalidAddressError
37
- unavailable(names, :failed, :invalid_output)
38
43
  end
39
44
 
40
45
  private
@@ -16,7 +16,7 @@ module Wireless
16
16
  observed_at.is_a?(Time)
17
17
  raise ArgumentError, "Invalid wireless snapshot"
18
18
  end
19
- super(target: CtxParser::Immutable.copy(target), dimensions: dimensions.dup.freeze,
19
+ super(target: CtxParser::Immutable.copy(target), dimensions: dimensions.to_h { |name, value| [ name, value ] }.freeze,
20
20
  observed_at: observed_at.dup.freeze)
21
21
  end
22
22
 
@@ -33,5 +33,6 @@ module Wireless
33
33
 
34
34
  # 展示对象身份与状态,避免泄露原始回显或凭据。
35
35
  def inspect = "#<#{self.class.name} complete=#{complete?}>"
36
+ alias to_s inspect
36
37
  end
37
38
  end
@@ -0,0 +1,32 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "ctx_parser/banner"
4
+
5
+ module Wireless
6
+ # 分页及终端编辑控制会破坏事实边界;正常换行和制表符仍是来源文本。
7
+ module Terminal
8
+ INVALID = /--[ \t]*More[ \t]*--|[\x00-\x08\x0b\x0c\x0e-\x1f\x7f]/i
9
+ private_constant :INVALID
10
+
11
+ UNSUPPORTED = /(?:\A|(?<=[\r\n]))[ \t]*(?:%[ \t]*)?(?:Unrecognized command\b|Wrong parameter\b|Invalid (?:input|command)\b|Unknown command\b|Error:[^\r\n]*\bcommand\b)[^\r\n]*/i
12
+
13
+ private_constant :UNSUPPORTED
14
+
15
+ # 错误响应须独占来源行;横幅正文不是设备命令诊断。
16
+ def self.unsupported?(text)
17
+ banner = CtxParser::Banner.new
18
+ text.split(/\r\n?|\n/).any? { |line| !banner.consume(line.strip) && UNSUPPORTED.match?(line) }
19
+ end
20
+
21
+ # 在字段清理之前检测终端残留,避免将编辑后的文本接受为事实。
22
+ def self.invalid_output?(text) = INVALID.match?(text)
23
+
24
+ # 运行回显无可恢复的来源树,返回首个受损来源行的安全诊断。
25
+ def self.issue(text)
26
+ return unless invalid_output?(text)
27
+
28
+ line = text.split(/\r\n?|\n/).find_index { |source| invalid_output?(source) }
29
+ { code: :invalid_terminal_output, line: line + 1 }
30
+ end
31
+ end
32
+ end
@@ -2,5 +2,5 @@
2
2
 
3
3
  module Wireless
4
4
  # 公开包版本,供 RubyGems 元数据和运行时调用方共用。
5
- VERSION = "0.1.0"
5
+ VERSION = "0.2.0"
6
6
  end
data/lib/wireless.rb CHANGED
@@ -12,7 +12,5 @@ require_relative "wireless/projection"
12
12
  require_relative "wireless/coverage"
13
13
  require_relative "wireless/configuration"
14
14
  require_relative "wireless/runtime_parser"
15
- require_relative "wireless/h3c_runtime"
16
15
  require_relative "wireless/neighbor_parser"
17
- require_relative "wireless/ap_session"
18
16
  require_relative "wireless/collector"
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: wireless-rb
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.1.0
4
+ version: 0.2.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Gatework contributors
@@ -51,20 +51,6 @@ dependencies:
51
51
  - - ">="
52
52
  - !ruby/object:Gem::Version
53
53
  version: '0'
54
- - !ruby/object:Gem::Dependency
55
- name: net-connector
56
- requirement: !ruby/object:Gem::Requirement
57
- requirements:
58
- - - ">="
59
- - !ruby/object:Gem::Version
60
- version: '0'
61
- type: :runtime
62
- prerelease: false
63
- version_requirements: !ruby/object:Gem::Requirement
64
- requirements:
65
- - - ">="
66
- - !ruby/object:Gem::Version
67
- version: '0'
68
54
  - !ruby/object:Gem::Dependency
69
55
  name: time
70
56
  requirement: !ruby/object:Gem::Requirement
@@ -98,9 +84,10 @@ files:
98
84
  - lib/wireless/configuration.rb
99
85
  - lib/wireless/coverage.rb
100
86
  - lib/wireless/fields.rb
101
- - lib/wireless/h3c/authentication_parser.rb
87
+ - lib/wireless/h3c/authentication.rb
102
88
  - lib/wireless/h3c/command_output.rb
103
89
  - lib/wireless/h3c/configuration.rb
90
+ - lib/wireless/h3c/document.rb
104
91
  - lib/wireless/h3c/parser.rb
105
92
  - lib/wireless/h3c/parsers/display_current_configuration.rb
106
93
  - lib/wireless/h3c/parsers/display_dot1x_connection.rb
@@ -113,16 +100,18 @@ files:
113
100
  - lib/wireless/h3c/parsers/display_wlan_client_verbose.rb
114
101
  - lib/wireless/h3c/parsers/display_wlan_statistics_client_ip_conflict.rb
115
102
  - lib/wireless/h3c/profile.rb
116
- - lib/wireless/h3c/session_parser.rb
117
- - lib/wireless/h3c/verbose_parser.rb
118
- - lib/wireless/h3c_runtime.rb
119
- - lib/wireless/huawei_runtime.rb
103
+ - lib/wireless/h3c/record_parser.rb
104
+ - lib/wireless/huawei/parser.rb
105
+ - lib/wireless/huawei/parsers/display_ap_all.rb
106
+ - lib/wireless/huawei/parsers/display_station_all.rb
107
+ - lib/wireless/huawei/profile.rb
120
108
  - lib/wireless/neighbor_parser.rb
121
109
  - lib/wireless/observation.rb
122
110
  - lib/wireless/parser_registry.rb
123
111
  - lib/wireless/projection.rb
124
112
  - lib/wireless/runtime_parser.rb
125
113
  - lib/wireless/snapshot.rb
114
+ - lib/wireless/terminal.rb
126
115
  - lib/wireless/version.rb
127
116
  homepage: https://github.com/gatework/wireless-rb
128
117
  licenses:
@@ -1,58 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- require_relative "verbose_parser"
4
-
5
- module Wireless
6
- module H3c
7
- # 复用详细会话边界,各命令独立声明其字段结构。
8
- class SessionParser < VerboseParser
9
- COMMON_FIELDS = { "ap name" => :ap_name, "ap id" => :ap_id, "radio id" => :radio_id,
10
- "ssid" => :ssid, "bssid" => :bssid, "vlan id" => :vlan, "service vlan id" => :vlan }.freeze
11
- DETAILS = [].freeze
12
-
13
- private
14
-
15
- # 解释当前命令记录,保留不完整行诊断。
16
- def parse_text(text, complete:)
17
- issues, records = [], []
18
- fields = tokens(text, issues)
19
- start = start_label(fields)
20
- current = nil
21
- fields.each do |label, raw, line|
22
- if start ? label == start : self.class::MAPPING[label] == self.class::IDENTITY.first
23
- current = {}
24
- records << current
25
- end
26
- unless current
27
- issues << { code: :missing_identity, line: line }
28
- next
29
- end
30
- assign_session_field(current, label, raw, issues, line)
31
- end
32
- records.reject! do |record|
33
- invalid = !valid_identity?(record)
34
- issues << { code: :invalid_identity } if invalid
35
- invalid
36
- end
37
- Fields.result(records, issues: issues, complete: complete, total: count(text, self.class::COUNT), identity: self.class::IDENTITY)
38
- end
39
-
40
- # 返回当前命令格式中用于开始新记录的字段。
41
- def start_label(fields)
42
- nil
43
- end
44
-
45
- # 要求具备关联当前记录所需的稳定身份字段。
46
- def valid_identity?(record)
47
- value = record[self.class::IDENTITY.first]
48
- value && !value.strip.empty?
49
- end
50
-
51
- # 将单个认证会话字段归一化到目标记录。
52
- def assign_session_field(record, label, raw, issues, line)
53
- details = self.class::DETAILS.include?(label) ? record : nil
54
- assign_field(record, self.class::MAPPING, label, raw, issues, line, details: details)
55
- end
56
- end
57
- end
58
- end