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
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: e202198d7c272a5d1463d5bfd91fb56057d440783d3e11f2316187673d296be3
4
- data.tar.gz: 2ce8943f91535c5950cc3ae0bec031edf5a5564debbfaaae1770938b2ebf9962
3
+ metadata.gz: 4da8d6c74654151d449ed7c71366f5ad2335b84a59caee076a1bc07ca49497b3
4
+ data.tar.gz: 6c2601cb0b091843987c26e68bfa4eea4f978ab457b99902ed4a04e4adf7cc24
5
5
  SHA512:
6
- metadata.gz: d4eacfd2e94f6228d10e230f4ad2b44bf103c835cc89463cbee4734db266589f8767f4a01cbafddf39b510c51e7b84cd841b5ea26818ba39278372630b42e4fe
7
- data.tar.gz: 7bb9649c07bb3b7d580ba47779ac10183a5df6d112500391db9853749b8a156b24ccce8e422836d4b677e0f85e70356b6908afdc02289f66c8f9d75d734f33ce
6
+ metadata.gz: 1992172ce7c985fb4afc713a956b69ea84c902edd58e576be928384ec346e136bbc6b978de677323d651514103ee3cbc4c6113edbbe710dafa404b6a9bc6f772
7
+ data.tar.gz: fe4ac1f5ed1b1a9084409167f7aeae5953cfbbf321c8d7226d0a9a4be96c50731cfff96858d086c42854b2d244c74ea50fc92f9c81e6e4b3c9b2a27dad8323dc
data/CHANGELOG.md CHANGED
@@ -2,6 +2,55 @@
2
2
 
3
3
  Changes to the public contract are recorded here. Versions follow Semantic Versioning.
4
4
 
5
+ ## 0.2.0 (2026-10-17)
6
+
7
+ - Route Huawei commands through Profile and standard Parser contracts, and validate caller arguments before reading output. Keep malformed device bodies as failed observations.
8
+ - Share terminal rejection detection across platforms.
9
+
10
+ - Keep AP login, queries, return verification, and failure close within one borrowed controller operation; never quit from the controller or an unexpected prompt.
11
+ - Leave busy controllers untouched, propagate cancellation, preserve independent cleanup/close failures, and require net-connector 0.11.1 for the optional AP adapter.
12
+ - Skip AP credential and adapter setup for empty inventories; keep AP setup errors separate from controller connection failures.
13
+
14
+ - Snapshot controller identity and AP selection before external session callbacks can mutate caller-owned inputs.
15
+ - Remove the mandatory net-connector dependency and default AP adapter loading; inject AP readers through `Collector.new(ap_session:)` and load `wireless/ap_session` explicitly when needed.
16
+ - Use the current shared parser and driver APIs without version constraints in the gemspec; applications own their deployment lockfiles.
17
+ - Reject pagination and editing controls before parsing H3C/Huawei runtime facts; retain damaged configuration source lines and mark their enclosing captures incomplete.
18
+
19
+ - Use the shared `CtxParser::Configuration`, `Results`, and `Commands` names and dimension indexes where applicable.
20
+
21
+ - Simplify H3C parsing to `Parser` and `RecordParser`, with shared `Authentication` field definitions; retain device-command parser names.
22
+ - Name H3C source documents `H3c::Document`, group Huawei parsing under `Huawei::Profile` and command-mapped `Huawei::Parsers`, and use `RuntimeParser` directly for H3C observations.
23
+ - Reject numeric overflow in measurements and rate conversions while retaining unrelated client facts.
24
+
25
+ - Rename domain APIs directly; callers must use the new names (no compatibility aliases).
26
+
27
+ - Expose seconds, Mbps, and MHz in measurement conversion names; keep ambiguous channel-only band evidence unknown.
28
+
29
+ - Name private collection operations by their scope: controller commands, AP neighbor batches, and individual AP neighbor reads.
30
+ - Breaking: map H3C AP/client online duration to `online_seconds`, and reserve AP `uptime_seconds` for the separate system uptime field.
31
+ - Preserve explicit composite authentication modes and opaque LLDP chassis identities instead of reducing their meaning.
32
+
33
+ ### Fixed
34
+
35
+ - Keep repeated Radio capability labels within their VHT, HT, HE MCS, or operational-rate subsection; accept documented BSSID and bandwidth aliases.
36
+ - Keep Radio coverage incomplete when an AP has no reported Radio evidence; AP counts only establish the AP inventory.
37
+ - Validate compound channel settings before assignment, and clear all related flags when channel or MAC username-format declarations conflict.
38
+ - Bind AP LLDP captures and evidence to the requested command and AP identity, rejecting contradictory skipped-success receipts.
39
+
40
+ - Reject conflicting or malformed totals and conflicting converted runtime fields; ambiguous fields cannot be restored by later declarations.
41
+ - Reject contradictory singleton settings within a configuration block, retain invalid source locations, and stop ambiguous authentication derivation.
42
+ - Preserve wireless declarations under unknown parent scopes as incomplete evidence instead of claiming an empty configuration.
43
+ - Validate authentication modes, explicit IP address families, and VLAN membership ranges; keep domain references separate from authentication activation.
44
+ - Index configuration entities, template bindings, and AP name selection; append LLDP descriptions without repeated whole-string copies.
45
+ - Traverse unsupported configuration trees iteratively instead of consuming the Ruby call stack.
46
+ - Keep observation, snapshot, and projection display strings metadata-only; validate observation identities and detach snapshot hash defaults.
47
+ - Keep empty coverage scopes unknown and reject non-finite freshness windows.
48
+ - Snapshot platform/model before callbacks and prevent AP credentials from replacing the borrowed controller session.
49
+
50
+ - Recognize unsupported command responses at line boundaries without interpreting SSIDs or banner text as errors.
51
+ - Keep paginated or damaged configurations and their source captures incomplete.
52
+ - Copy validated AP credentials so later caller mutation cannot alter login interactions.
53
+
5
54
  ## 0.1.0 (2026-10-16)
6
55
 
7
56
  ### Added
data/README-zh.md CHANGED
@@ -21,7 +21,7 @@ gem "wireless-rb", require: "wireless"
21
21
 
22
22
  然后执行 `bundle install`;也可直接执行 `gem install wireless-rb`。Ruby 加载入口为 `wireless`,公开命名空间为 `Wireless`;gem 包名与 Ruby 命名空间分别维护。
23
23
 
24
- 模块运行依赖:[`ctx_parser`](https://github.com/gatework/ctx_parser), [`ctx_driver`](https://github.com/gatework/ctx_driver), [`net-connector`](https://github.com/gatework/net-connector)。 gemspec 的运行依赖不添加版本约束;应用应提交自己的 `Gemfile.lock`,保持部署可复现。使用本库不需要启动 Rails 或配置兄弟模块的本地路径。
24
+ 模块使用 [`ctx_parser`](https://github.com/gatework/ctx_parser) 和 [`ctx_driver`](https://github.com/gatework/ctx_driver),gemspec 不限定依赖版本。离线解析及注入控制器会话的采集均不安装或加载 `net-connector`。应用应提交自己的 `Gemfile.lock`,保持部署可复现;使用本库不需要启动 Rails 或配置兄弟模块的本地路径。
25
25
 
26
26
  发布产物和校验值见 [GitHub Releases](https://github.com/gatework/wireless-rb/releases),已发布版本以 [RubyGems](https://rubygems.org/gems/wireless-rb) 为准。
27
27
 
@@ -67,18 +67,51 @@ end
67
67
 
68
68
  ## 公开契约与限制
69
69
 
70
+ 非法平台、型号、维度参数及非布尔 `complete` 抛出 `ArgumentError`;设备正文格式错误返回失败观测。
71
+
70
72
  `Configuration.new.parse(text, complete: false)` 解析控制器配置。`RuntimeParser.new(model: nil).parse(name, text, platform: "h3c", complete: false)` 解析单个运行维度;`parse_many` 为同一命令的多个维度共享解析。`NeighborParser` 为邻居观察补充 AP 上下文。
71
73
 
72
74
  输入完整性由调用方确认。未知语法、身份重复、数量矛盾和截断输出保持部分或失败。配置中的最大发射功率不能当作当前测量功率;PSK、密码、认证 URL 不可进入快照。
73
75
 
74
- `Collector#collect(connector:, target:, platform:, model: nil, dimensions:, ap_connection: nil, ap_names: [])` 使用注入的 connector。内置 `ApSession` 通过显式运行依赖 `net-connector` 处理中转会话;请求 LLDP 时必须通过 `ap_connection` 显式提供 AP 凭据。宿主负责设备身份和连接设置,解析器不会推断 AP console 开启或 AP 凭据。Rails 库存投影、锁、加密、API 和 `Wireless::Sync` 属于宿主,不随本 gem 发布。
76
+ `Collector#collect(connector:, target:, platform:, model: nil, dimensions:, ap_connection: nil, ap_names: [])` 使用宿主注入的 connector,普通采集仅要求会话提供 `connected?` 和 `send_commands`。AP LLDP 通过 `Collector.new(ap_session: factory)` 显式注入;factory 接收 `controller:` 和按需解析的 `ap_connection` 关键字,返回提供 `read(access_point:)` 的对象。需要查询 AP 但未注入工厂时,仅 LLDP 返回 `ap_session_required`,不读取 AP 凭据,也不影响控制器清单。
77
+
78
+ 内置 AP 登录适配器可选使用。宿主须自行声明 `gem "net-connector", "~> 0.11.1", require: false`,并显式加载和注入:
79
+
80
+ ```ruby
81
+ require "wireless"
82
+ require "wireless/ap_session"
83
+
84
+ collector = Wireless::Collector.new(
85
+ ap_session: ->(controller:, **credentials) { Wireless::ApSession.new(controller: controller, **credentials) }
86
+ )
87
+ ```
88
+
89
+ 适配器要求通过 `ap_connection` 显式提供 AP 密码,可选用户名;自定义适配器可自行管理凭据。`read` 返回包含 `result: CtxDriver::Capture`、可选 `error:` / `cleanup_error:` / `close_error:` 和 `skipped:` 的 Hash。控制器和 AP 凭据、会话生命周期归宿主或适配器管理,解析器不推断 AP console 或凭据。
90
+
91
+ AP 接入复用已连接的控制器及其操作租约,不另建 AP 传输连接。登录、只读查询、确认返回及失败关闭都在同一租约中;控制器忙碌时不关闭或破坏其他操作。只有仍处于已确认的 AP 提示符时才发送 `quit`,取消继续传播,退出与关闭故障独立保留。空 AP 清单不解析凭据,也不创建适配器。
75
92
 
76
93
  详细厂商语义、历史验证记录和 Motor 专用集成说明保留在[架构参考](docs/architecture-zh.md)。其中宿主集成代码需要 Motor,不属于独立 gem API。适配层位于 [Motor 仓库](https://github.com/gatework/motor/tree/main/backend/lib) 的 `backend/lib/baseline`、`backend/lib/vxlan` 和 `backend/lib/wireless`。
77
94
 
78
95
  生命周期 connector 是宿主适配器,提供 `open { |session| }`,VXLAN 和 Wireless 还要求 `platform`;原生 `Net::Connector::Base` 实例并不提供这一宿主生命周期协议。已连接的原生会话可传入 `CtxDriver::Reader` 或 `CtxChecker::Collector#collect_session`,不会隐式连接或重试。
79
96
 
97
+ 运行字段或同一配置声明块内的单值设置发生冲突时,移除歧义事实,相关来源保持不完整;同块后续声明不能恢复该值。独立的顺序配置块保留各自的来源快照。未知父级下的无线声明不能被当作空配置。观测名称必须为 Symbol,显示字符串只含元数据。空覆盖范围保持未知,新鲜度时间窗必须为有限非负数。
98
+
99
+ ## 领域术语
100
+
101
+ H3C `Online time` 保存为 `online_seconds`,AP 的 `System uptime` 保存为 `uptime_seconds`,两者对应[不同的设备字段](https://www.h3c.com/cn/d_202606/2850182_30005_0.htm)。显式认证模式优先于域引用;组合和回退模式保留顺序,不归并为单一认证方式,依据 [H3C 认证命令参考](https://www.h3c.com/en/d_202106/1418918_294551_0.htm)。LLDP 的不透明机箱标识保留大小写,明确为 MAC 类型时才归一化。
102
+
103
+ H3C 命令解析器使用 `H3c::Parser` 校验命令回显,使用 `H3c::RecordParser` 读取带字段标签的记录。`H3c::Authentication` 复用认证字段定义,`H3c::Document` 保留配置来源行;Huawei 使用 `Huawei::Profile` 和 `Huawei::Parsers` 命令解析器,遵循相同参数契约。
104
+
105
+ AP、射频、BSS 和关联客户端具有不同身份。测量转换方法明确返回单位:`Fields.duration_seconds`、`Fields.rates_mbps`、`Fields.channel_width_mhz`。`Fields.frequency_band` 需要无歧义的频段标签或频段专属 PHY 证据;仅有信道号或通用 `802.11ax` 标签时保持未知。参见 [Cisco 的 6 GHz 信道示例](https://www.cisco.com/c/en/us/support/docs/wireless/catalyst-9166-series-access-points/220526-configure-and-verify-wi-fi-6e-band-opera.html)。
106
+
107
+ Radio 能力详情按子节保存:`details[:vht_mcs]`、`[:ht_mcs]`、`[:he_mcs]` 和 `[:operational_rates]` 分别拥有 `supported`、`mandatory` 等同名标签。AP 总数只证明 AP 覆盖;缺少 Radio 回显的 AP 会使 Radio 视图保持不完整,AP 视图仍可完整。AP LLDP 适配器须返回对应命令的回执,AP 身份和尝试状态由采集器确定。
108
+
109
+ `authentication_type` 是已识别认证配置的摘要。PSK 与 Portal 等可叠加机制分别保留在 `configuration[:akm_modes]` 和 `configuration[:portal]`;核查全部启用机制的基线应读取这些字段。
110
+
80
111
  ## 开发与测试
81
112
 
113
+ 共享类按实际职责命名,平台信息放在命名空间中。直接对应设备命令的解析器保留文件名和类名。
114
+
82
115
  ```sh
83
116
  bundle install
84
117
  bundle exec rake
@@ -90,6 +123,8 @@ bundle exec rake build
90
123
 
91
124
  使用声明的最低 Ruby 版本检查兼容性。方法用途统一使用简明中文注释;复杂实现还需说明业务规则、实现思路及关键分支。保持公开返回值与错误契约。真实回显和凭据不可进入示例、fixture、日志或发布包。
92
125
 
126
+ 运行 `bundle exec ruby -Ilib benchmark/tables.rb`,比较 500、2,000、5,000 条合成模板、绑定及 LLDP 续行的本地开销。每组核对解析结果,报告三次采样中最快的耗时和对象分配数;这些结果不代表设备或网络性能。
127
+
93
128
  ## 发布
94
129
 
95
130
  本目录可独立维护为 [wireless-rb](https://github.com/gatework/wireless-rb) 仓库。更新 `VERSION` 和 `CHANGELOG.md`,运行上述验证与构建命令,检查包内文件,再创建 `vVERSION` 标签。
data/README.md CHANGED
@@ -21,7 +21,7 @@ gem "wireless-rb", require: "wireless"
21
21
 
22
22
  Then run `bundle install`, or install directly with `gem install wireless-rb`. The require path is `wireless` and the public namespace is `Wireless`; package names and Ruby namespaces are intentionally separate.
23
23
 
24
- Internal runtime dependencies: [`ctx_parser`](https://github.com/gatework/ctx_parser), [`ctx_driver`](https://github.com/gatework/ctx_driver), [`net-connector`](https://github.com/gatework/net-connector). The gemspec declares runtime dependencies without version constraints. Applications should commit their own `Gemfile.lock` to make deployments reproducible. No Rails boot or sibling checkout paths are required.
24
+ Runtime dependencies include [`ctx_parser`](https://github.com/gatework/ctx_parser) and [`ctx_driver`](https://github.com/gatework/ctx_driver), without version constraints in the gemspec. Offline parsing and injected controller collection do not install or load `net-connector`. Applications should commit their own `Gemfile.lock` to make deployments reproducible. No Rails boot or sibling checkout paths are required.
25
25
 
26
26
  Release artifacts and checksums are available from [GitHub Releases](https://github.com/gatework/wireless-rb/releases). Check [RubyGems](https://rubygems.org/gems/wireless-rb) for published versions.
27
27
 
@@ -69,16 +69,45 @@ end
69
69
 
70
70
  `Configuration.new.parse(text, complete: false)` parses controller configuration. `RuntimeParser.new(model: nil).parse(name, text, platform: "h3c", complete: false)` parses one runtime dimension; `parse_many` shares parsing for dimensions from the same command. `NeighborParser` supplies the AP context to neighbor observations.
71
71
 
72
- The caller must establish input completeness. Unknown syntax, duplicate identities, count mismatches, and truncated output remain partial or failed. A configured maximum transmit power is not a current measured transmit power. PSKs, passwords, and authentication URLs must not enter snapshots.
72
+ Invalid platform/model/dimension arguments and non-boolean `complete` raise `ArgumentError`; malformed device text produces a failed observation. The caller must establish input completeness. Unknown syntax, duplicate identities, count mismatches, and truncated output remain partial or failed. A configured maximum transmit power is not a current measured transmit power. PSKs, passwords, and authentication URLs must not enter snapshots.
73
73
 
74
- `Collector#collect(connector:, target:, platform:, model: nil, dimensions:, ap_connection: nil, ap_names: [])` uses an injected connector. `net-connector` is an explicit runtime dependency for the included `ApSession`; an LLDP request needs explicit AP credentials through `ap_connection`. The host owns device identity and connection settings. AP console activation and AP credentials are never inferred by the parser. Rails inventory projection, locking, encryption, APIs, and `Wireless::Sync` belong to the host and are not shipped in this gem.
74
+ Conflicting runtime fields and singleton settings within one configuration block are excluded and keep their source evidence incomplete; later declarations in that block cannot restore the ambiguous value. Separate sequential configuration blocks retain their own source captures. Unknown nested wireless declarations cannot certify an empty configuration. Observation names must be symbols; display strings contain metadata only. An empty coverage scope is unknown, and freshness windows must be finite and nonnegative.
75
+
76
+ `Collector#collect(connector:, target:, platform:, model: nil, dimensions:, ap_connection: nil, ap_names: [])` uses a host-supplied connector; ordinary collection only requires `connected?` and `send_commands` on its sessions. For AP LLDP, inject `Collector.new(ap_session: factory)`. The factory receives `controller:` plus lazily resolved `ap_connection` keywords and returns an object with `read(access_point:)`. When APs need querying but no factory was supplied, only LLDP reports `ap_session_required`; credentials are not read and controller observations remain available.
77
+
78
+ The included AP login adapter is optional. Declare `gem "net-connector", "~> 0.11.1", require: false` in the host application, then load and inject it explicitly:
79
+
80
+ ```ruby
81
+ require "wireless"
82
+ require "wireless/ap_session"
83
+
84
+ collector = Wireless::Collector.new(
85
+ ap_session: ->(controller:, **credentials) { Wireless::ApSession.new(controller: controller, **credentials) }
86
+ )
87
+ ```
88
+
89
+ This adapter requires an AP password and optional username through `ap_connection`; custom adapters can own their credentials. `read` returns a hash containing `result: CtxDriver::Capture`, optional `error:` / `cleanup_error:` / `close_error:`, and `skipped:`. The host or adapter owns credentials and session lifecycle; parsers never infer AP console activation or credentials.
90
+
91
+ AP access reuses the connected controller and its operation lease; it does not open a separate AP transport. Login, read-only queries, confirmed return, and any failure close share that lease. A busy controller is left untouched. The adapter sends `quit` only from the confirmed AP prompt, and preserves cancellation and independent cleanup failures. Empty AP inventories do not resolve credentials or construct adapters.
75
92
 
76
93
  Detailed vendor behavior, historical validation notes, and Motor-only integration are preserved in [the architecture reference](docs/architecture-zh.md). Host integration examples in that reference require Motor; they are not standalone gem APIs. Motor adapters live under `backend/lib/baseline`, `backend/lib/vxlan`, and `backend/lib/wireless` in the [Motor repository](https://github.com/gatework/motor/tree/main/backend/lib).
77
94
 
78
95
  The lifecycle connector is a host adapter exposing `open { |session| }` (and `platform` for VXLAN and Wireless). It is distinct from a native `Net::Connector::Base` device instance. Borrowed native sessions are accepted by `CtxDriver::Reader` and `CtxChecker::Collector#collect_session`; they are never implicitly connected or retried.
79
96
 
97
+ ## Domain vocabulary
98
+
99
+ H3C command parsers share `H3c::Parser` for command-output validation and `H3c::RecordParser` for labeled records. `H3c::Authentication` supplies shared authentication fields; `H3c::Document` preserves configuration source lines. Huawei uses `Huawei::Profile` and command parsers under `Huawei::Parsers`, with the same parameter contract.
100
+
101
+ APs, radios, BSSs, and associated clients have distinct identities. H3C `Online time` is `online_seconds`; AP `System uptime` is `uptime_seconds`, as shown by the [distinct device fields](https://www.h3c.com/cn/d_202606/2850182_30005_0.htm). Explicit authentication modes take precedence over configured domain references, and combined or fallback modes retain their order instead of becoming a single authentication method; see the [H3C authentication reference](https://www.h3c.com/en/d_202106/1418918_294551_0.htm). Opaque LLDP chassis IDs preserve case, while an explicit MAC subtype is normalized. Measurement conversion names expose output units: `Fields.duration_seconds`, `Fields.rates_mbps`, and `Fields.channel_width_mhz`. `Fields.frequency_band` requires an unambiguous frequency-band label or band-specific PHY evidence. A channel number alone, or a generic `802.11ax` label, does not establish a band; channel numbers are not accepted as band evidence. See the [Cisco 6-GHz channel examples](https://www.cisco.com/c/en/us/support/docs/wireless/catalyst-9166-series-access-points/220526-configure-and-verify-wi-fi-6e-band-opera.html).
102
+
103
+ Radio capability details preserve their subsection: `details[:vht_mcs]`, `[:ht_mcs]`, `[:he_mcs]`, and `[:operational_rates]` each own labels such as `supported` and `mandatory`. AP totals establish AP coverage; an AP without reported Radio evidence leaves the Radio view incomplete, while the AP view can remain complete. AP LLDP adapters must return a capture for the requested LLDP command; the collector owns AP identity and attempted-status metadata.
104
+
105
+ `authentication_type` summarizes recognized authentication configuration. Combined deployment layers such as PSK and Portal remain in `configuration[:akm_modes]` and `configuration[:portal]`; a baseline checking all enabled mechanisms must inspect those fields.
106
+
80
107
  ## Development and tests
81
108
 
109
+ Name shared classes by their responsibility and use the namespace for platform context. Preserve filenames and class names that directly match device commands.
110
+
82
111
  ```sh
83
112
  bundle install
84
113
  bundle exec rake
@@ -88,6 +117,8 @@ bundle exec rake build
88
117
 
89
118
  `bundle exec rake` runs RSpec, RuboCop, and the offline examples. Tests live in `spec/`, following the existing project's RSpec convention; fixtures use synthetic or sanitized evidence. Add regression cases for successful, incomplete, unsupported, and malformed inputs when extending a parser. Offline tests do not certify all device versions or replace live integration checks.
90
119
 
120
+ Run `bundle exec ruby -Ilib benchmark/tables.rb` for synthetic 500, 2,000, and 5,000-entry template, binding, and LLDP continuation cases. Each case validates its result and reports the fastest of three local samples plus object allocations; it does not measure device or network performance.
121
+
91
122
  Use the declared minimum Ruby version for compatibility checks. Document source methods and non-obvious business logic in concise Chinese, preserve public return/error contracts, and never add raw device captures or credentials to examples, fixtures, logs, or release archives.
92
123
 
93
124
  ## Release
@@ -10,17 +10,17 @@ Motor 自有的无线业务实现,参考 Netstack 已验证的命令、字段
10
10
 
11
11
  `Motor::Device → Motor::Devices::Connector → Wireless::Collector / CtxDriver::Reader → Configuration / RuntimeParser / NeighborParser → Snapshot → Sync → AssetInventory / DetailInventory`
12
12
 
13
- - 公共 `Connector` 管理凭据、严格主机密钥校验和会话生命周期;[`CtxDriver::Reader`](https://github.com/gatework/ctx_driver) 通过 net-connector 执行单条查询、验证响应并记录来源摘要。每台控制器使用一个会话顺序采集,共享命令只执行、解析一次;单脚本失败不丢弃其他结果,断线后不自动重连。AP 子会话仍由 `ApSession` 管理,每个 AP 的邻居读取和解析分别隔离失败。
13
+ - 公共 `Connector` 管理凭据、严格主机密钥校验和会话生命周期;[`CtxDriver::Reader`](https://github.com/gatework/ctx_driver) 通过宿主注入的会话执行单条查询、验证响应并记录来源摘要。每台控制器使用一个会话顺序采集,共享命令只执行、解析一次;单脚本失败不丢弃其他结果,断线后不自动重连。AP 子会话由 `Collector.new(ap_session: factory)` 注入,每个 AP 的邻居读取和解析分别隔离失败。内置 `ApSession` 仅在宿主自行安装 net-connector 0.11.1 或同系列后续补丁 并显式 `require "wireless/ap_session"` 后可用;默认加载、配置解析和普通控制器采集均不引入传输库。
14
14
  - 解析结果使用与数据库字段对应的 Hash。H3C 覆盖 AP、Radio、BSS、Client、AP 组、服务模板、802.1X,以及快照中的 MAC 认证和阻断客户端。Huawei 保留已验证的 AP、Client 表格格式,其他维度明确标记不支持。
15
15
  - 配置事实和运行测量分开保存;配置中的最大功率不能当作当前发射功率。PSK、密码、原始配置及认证 URL 不进入快照或错误信息。
16
- - AP LLDP 需要显式请求,使用控制器中转和显式 AP 凭据。确认 AP 提示符后才读取,退出时确认原控制器提示符;不自动开启 AP console。
16
+ - AP LLDP 需要显式请求和注入 AP 会话工厂;内置适配器使用控制器中转和显式 AP 凭据。确认 AP 提示符后才读取,退出时确认原控制器提示符;若已返回控制器则不再发送 quit,遇到其他提示符则隔离连接。进入 AP、读取、退出及失败关闭均位于同一次控制器租约中,不建立独立 AP 连接;控制器忙碌时不夺取或关闭其他操作。取消继续传播,原始取消不会被清理阶段的第二次取消覆盖;退出和关闭失败分别保留为 cleanup_error 和 close_error。不自动开启 AP console。
17
17
  - 外部 I/O 完成后锁控制器,复核控制器、原快照和资产版本,原子提交资产、配置明细、快照及 revision。采集事实与库存投影分开:缺父 AP/Radio 的合法记录保留在原始快照,库存投影记录 `parent_unavailable`,其他独立实体继续入库;存在依赖未满足的维度禁止清理缺失项。身份歧义、模型校验和数据库异常仍使整个事务回滚。AP 档案只标记缺失,人工管理字段保留。
18
18
  - 管理 API、Netdisco 同步和既有 Rake 任务名不变。`Motor::Wireless::CliSync` 委托新实现。虚拟控制器及信道继续查询现有表。
19
19
 
20
20
 
21
- H3C 具体策略位于 [`lib/wireless/h3c`](../lib/wireless/h3c/profile.rb),10 条命令分别实现为 `parsers/display_*.rb`。`H3c::Parser` 管理平台输入边界,`VerboseParser` 与 `SessionParser` 复用标签和会话结构,各命令类维护自己的字段和身份规则。`Profile` 统一声明命令、解析类及型号覆盖,`ParserRegistry` 识别 `h3c` / `h3c_wireless`。公共抽象及型号扩展见 [`../ctx_parser`](https://github.com/gatework/ctx_parser)。
21
+ H3C 具体策略位于 [`lib/wireless/h3c`](../lib/wireless/h3c/profile.rb),10 条命令分别实现为 `parsers/display_*.rb`。`H3c::Parser` 管理平台输入边界,`H3c::RecordParser` 读取标签字段并划分记录;`H3c::Authentication` 复用认证字段和身份定义,各命令类保留自己的格式规则。`Profile` 统一声明命令、解析类及型号覆盖,`ParserRegistry` 识别 `h3c` / `h3c_wireless`。公共抽象及型号扩展见 [`../ctx_parser`](https://github.com/gatework/ctx_parser)。
22
22
 
23
- 配置与运行命令共用 `H3c::CommandOutput` 的编码和命令错误边界;配置保留原始换行,运行表格规范化为 LF。`DisplayCurrentConfiguration` 解释语法,`H3c::Configuration` 维护实体身份、AP 组/型号/Radio 的合法归属及模板认证类型。嵌套记录与来源路径由 `Configuration::Scope` 一起构建,逐行状态和捕获复用 `CtxParser::ConfigurationParser`,不在厂商类内重复实现。
23
+ `H3c::Document` 组织配置来源行、横幅与完整性诊断。配置与运行命令共用 `H3c::CommandOutput` 的编码和命令错误边界;配置保留原始换行,运行表格规范化为 LF。共享 `Wireless::Terminal` 在字段清理前检测分页和编辑控制:运行回显受损时拒绝生成事实,配置文档则逐行标记无效来源,保留其他配置及其捕获;横幅内容仍作为普通文本隔离。`DisplayCurrentConfiguration` 解释语法,`H3c::Configuration` 维护实体身份、AP 组/型号/Radio 的合法归属及模板认证类型。嵌套记录与来源路径由 `Configuration::Scope` 一起构建,逐行状态和捕获复用 `CtxParser::Configuration`,不在厂商类内重复实现。
24
24
 
25
25
  顶层声明只在配置根节点识别。AP、型号或 Radio 内出现 `sysname`、`wlan ap-group`、`wlan service-template` 等顶层声明时,保留未知诊断,不提升为全局对象;配置及共享的组/模板维度均为部分结果,入库保留已有完整明细。重复实体保留首条并标记为部分结果。
26
26
 
@@ -1,5 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ # 可选入口单独声明最低连接器版本,核心解析不激活此依赖。
4
+ gem "net-connector", "~> 0.11.1"
3
5
  require "net/connector"
4
6
  require "ctx_driver"
5
7
  require_relative "fields"
@@ -23,79 +25,138 @@ module Wireless
23
25
 
24
26
  # 校验 AP 凭据,借用控制器会话进行嵌套接入。
25
27
  def initialize(controller:, password:, username: nil)
28
+ unless %i[connected? current_prompt with_operation execute_operation close].all? { |method| controller.respond_to?(method) }
29
+ raise ArgumentError, "Controller must provide an existing operation-capable session"
30
+ end
26
31
  unless password.is_a?(String) && !password.strip.empty? && !/[\x00-\x1f\x7f]/.match?(password) &&
27
- (username.nil? || (username.is_a?(String) && !/[\x00-\x1f\x7f]/.match?(username)))
32
+ (username.nil? || (username.is_a?(String) && !username.strip.empty? && !/[\x00-\x1f\x7f]/.match?(username)))
28
33
  raise ArgumentError, "Invalid AP credentials"
29
34
  end
30
- @controller, @password, @username = controller, password, username
35
+ @controller, @password, @username = controller, password.dup.freeze, username&.dup&.freeze
31
36
  @usable = true
32
37
  end
33
38
 
34
- # 嵌套登录 AP 读取 LLDP,并确认返回原控制器提示符。
39
+ # 借用控制器操作租约;进入 AP、查询、退出和失败关闭均不能与其他查询交错。
35
40
  def read(access_point:)
36
- return { error: :controller_session_lost, skipped: true } unless @usable && @controller.connected?
41
+ return { error: :controller_session_lost, skipped: true } unless @usable
42
+ return { error: :ap_address_unavailable, skipped: true } unless access_point.is_a?(Hash)
37
43
  return { error: :ap_not_online, skipped: true } unless Fields.online_status(access_point[:operational_status]) == "online"
38
- address = Fields.ip(access_point[:management_ip])
39
- return { error: :ap_address_unavailable, skipped: true } unless address && IPAddr.new(address).ipv4? && address != "0.0.0.0"
44
+ address = address_for(access_point)
45
+ return { error: :ap_address_unavailable, skipped: true } unless address
40
46
 
41
47
  outcome = {}
48
+ acquired = false
42
49
  @controller.with_operation(:wireless_ap_lldp) do
43
- original_prompt = prompt
44
- raise Error, :controller_prompt_mismatch unless DEVICE_PROMPT.match?(original_prompt)
45
- entered = false
46
- returned = false
50
+ acquired = true
51
+ unless @usable && @controller.connected?
52
+ @usable = false
53
+ next outcome.merge!(error: :controller_session_lost, skipped: true)
54
+ end
55
+ collect_neighbors(address, outcome)
56
+ end
57
+ outcome
58
+ rescue StandardError => error
59
+ # 未取得租约时不改变共享控制器状态,也不能关闭其他操作持有的连接。
60
+ outcome ||= {}
61
+ outcome[:error] ||= error_code(error)
62
+ outcome[:skipped] = true unless acquired
63
+ outcome
64
+ end
65
+
66
+ # 展示对象身份与状态,避免泄露原始回显或凭据。
67
+ def inspect = "#<#{self.class.name} usable=#{@usable}>"
68
+
69
+ private
70
+
71
+ # 地址验证只发生在接入前;后续协议错误不能被误报成未尝试的地址失败。
72
+ def address_for(access_point)
73
+ address = Fields.ip_address(access_point[:management_ip])
74
+ address if address && IPAddr.new(address).ipv4? && address != "0.0.0.0"
75
+ rescue ArgumentError, IPAddr::Error
76
+ nil
77
+ end
78
+
79
+ # 同一次控制器租约覆盖完整嵌套操作,未确认返回前始终视为不可复用。
80
+ def collect_neighbors(address, outcome)
81
+ original_prompt = prompt
82
+ raise Error, :controller_prompt_mismatch unless DEVICE_PROMPT.match?(original_prompt)
83
+
84
+ ap_prompt = nil
85
+ returned = false
86
+ @usable = false
87
+ begin
88
+ login = execute("telnet #{address}", DEVICE_PROMPT, timeout: 10, interactions: interactions)
89
+ confirm(login)
90
+ current = prompt
91
+ returned = current == original_prompt
92
+ ap_prompt = current if !returned && DEVICE_PROMPT.match?(current)
93
+ raise Error, :ap_authentication_failed if AUTHENTICATION_FAILURE.match?(login.output)
94
+ raise Error, :ap_connection_failed if CONNECTION_FAILURE.match?(login.output)
95
+ raise Error, :ap_login_not_confirmed unless ap_prompt
96
+
97
+ reader = CtxDriver::Reader.new(session: @controller)
98
+ preparation = reader.read("screen-length disable")
99
+ raise Error, preparation.failure.fetch(:code) unless preparation.success?
100
+ raise Error, :ap_session_lost unless prompt == ap_prompt
101
+
102
+ result = reader.read(COMMAND)
103
+ raise Error, :ap_session_lost if result.success? && prompt != ap_prompt
104
+ outcome[:result] = result
105
+ raise Error, result.failure.fetch(:code) unless result.success?
106
+ rescue StandardError => error
107
+ outcome[:error] = error_code(error)
108
+ ensure
109
+ original_error = $!
47
110
  begin
48
- interactions = [ Net::Connector::Interaction.new(/password:\s*\z/i, "#{@password}\n", sensitive: true, limit: 1, capture: false) ]
49
- interactions.unshift(Net::Connector::Interaction.new(/(?:login|username):\s*\z/i, "#{@username}\n", sensitive: true, limit: 1, capture: false)) if @username
50
- login = execute("telnet #{address}", DEVICE_PROMPT, timeout: 10, interactions: interactions)
51
- confirm(login)
111
+ restore(original_prompt, ap_prompt, returned, outcome)
112
+ rescue Exception # rubocop:disable Lint/RescueException -- 清理期间的二次取消不能覆盖原有取消,所有异常仍向外传播。
113
+ raise original_error, cause: original_error.cause if original_error
114
+ raise
115
+ end
116
+ end
117
+ end
118
+
119
+ # 登录凭据只进入有次数限制的敏感交互,不构造独立 AP 连接。
120
+ def interactions
121
+ values = [ Net::Connector::Interaction.new(/password:\s*\z/i, "#{@password}\n", sensitive: true, limit: 1, capture: false) ]
122
+ if @username
123
+ values.unshift(Net::Connector::Interaction.new(/(?:login|username):\s*\z/i, "#{@username}\n", sensitive: true, limit: 1, capture: false))
124
+ end
125
+ values
126
+ end
127
+
128
+ # 仅从已确认的 AP 提示符退出;已回控制器时不再发送 quit。
129
+ def restore(original_prompt, ap_prompt, returned, outcome)
130
+ if @controller.connected?
131
+ current = prompt
132
+ if ap_prompt && current == original_prompt
133
+ returned = true
134
+ elsif ap_prompt && current == ap_prompt
135
+ confirm(execute("quit", exact_prompt(original_prompt)))
52
136
  returned = prompt == original_prompt
53
- # 只有提示符已切换到 AP 才确认登录,命令成功返回控制器不代表接入成功。
54
- entered = prompt != original_prompt && DEVICE_PROMPT.match?(prompt)
55
- raise Error, :ap_authentication_failed if AUTHENTICATION_FAILURE.match?(login.output)
56
- raise Error, :ap_connection_failed if CONNECTION_FAILURE.match?(login.output)
57
- raise Error, :ap_login_not_confirmed unless entered
58
- reader = CtxDriver::Reader.new(session: @controller)
59
- preparation = reader.read("screen-length disable")
60
- raise Error, preparation.failure.fetch(:code) unless preparation.success?
61
-
62
- result = reader.read(COMMAND)
63
- outcome[:result] = result
64
- raise Error, result.failure.fetch(:code) unless result.success?
65
- rescue Error, Net::Connector::Error => error
66
- outcome[:error] = error.is_a?(Error) ? error.code : Net::Connector::ErrorMetadata.code(error.code) || :collection_failed
67
- ensure
68
- begin
69
- # 已进入 AP 才发送退出;必须确认返回原提示符后才能继续复用控制器会话。
70
- if entered && @controller.connected?
71
- confirm(execute("quit", exact_prompt(original_prompt)))
72
- returned = prompt == original_prompt
73
- end
74
- @usable = returned && @controller.connected? && prompt == original_prompt
75
- rescue Error, Net::Connector::Error
76
- @usable = false
77
- end
78
- outcome[:cleanup_error] = :ap_exit_failed unless @usable
79
137
  end
138
+ @usable = returned && @controller.connected? && prompt == original_prompt
80
139
  end
81
- outcome
82
- rescue ArgumentError, IPAddr::InvalidAddressError
83
- { error: :ap_address_unavailable, skipped: true }
84
- rescue Error, Net::Connector::Error => error
140
+ rescue StandardError
85
141
  @usable = false
86
- { error: error.is_a?(Error) ? error.code : Net::Connector::ErrorMetadata.code(error.code) || :collection_failed }
87
142
  ensure
88
- begin
89
- @controller.close if !@usable && @controller.connected?
90
- rescue Net::Connector::Error
91
- outcome[:cleanup_error] = :session_close_failed if outcome
143
+ unless @usable
144
+ outcome[:cleanup_error] = :ap_exit_failed
145
+ close_controller(outcome)
92
146
  end
93
147
  end
94
148
 
95
- # 展示对象身份与状态,避免泄露原始回显或凭据。
96
- def inspect = "#<#{self.class.name} usable=#{@usable}>"
149
+ # 关闭仍位于控制器租约中;退出与关闭失败独立保存,不覆盖已取得的观测。
150
+ def close_controller(outcome)
151
+ @controller.close
152
+ rescue StandardError
153
+ outcome[:close_error] = :session_close_failed
154
+ end
97
155
 
98
- private
156
+ # 所有失败只暴露稳定代码,不把连接器消息或凭据写入领域观测。
157
+ def error_code(error)
158
+ error.is_a?(Error) ? error.code : CtxDriver::Failure.code(error, :collection_failed)
159
+ end
99
160
 
100
161
  # 渲染当前终端提示符,用于安全核对嵌套会话身份。
101
162
  def prompt = Net::Connector::TerminalRenderer.render(@controller.current_prompt.to_s).strip
@@ -4,24 +4,25 @@ require_relative "parser_registry"
4
4
  require "ctx_driver"
5
5
  require_relative "runtime_parser"
6
6
  require_relative "snapshot"
7
- require_relative "ap_session"
8
7
  require_relative "neighbor_parser"
9
8
 
10
9
  module Wireless
11
10
  # 按单控制器顺序采集,去重共享查询,并区分失败与跳过维度。
12
11
  class Collector
13
12
  H3C_COMMANDS = H3c::Profile::COMMANDS.except(:lldp).freeze
14
- HUAWEI_COMMANDS = { access_points: "display ap all", clients: "display station all" }.freeze
15
13
  DEFAULT_DIMENSIONS = H3C_COMMANDS.keys.freeze
16
14
 
17
- # 注入为顺序命令观测生成时间戳的时钟。
18
- def initialize(clock: -> { Time.now.utc })
19
- @clock = clock
15
+ # 注入时钟和可选 AP 会话工厂;核心采集不加载具体传输。
16
+ def initialize(clock: -> { Time.now.utc }, ap_session: nil)
17
+ raise ArgumentError, "AP session factory must be callable" unless ap_session.nil? || ap_session.respond_to?(:call)
18
+
19
+ @clock, @ap_session = clock, ap_session
20
20
  end
21
21
 
22
22
  # 通过调用方持有的连接器顺序采集请求维度。
23
23
  def collect(connector:, target:, platform:, model: nil, dimensions: DEFAULT_DIMENSIONS, ap_connection: nil, ap_names: [])
24
- platform = platform.to_s
24
+ platform = platform.to_s.dup.freeze
25
+ model = model.dup.freeze if model.is_a?(String)
25
26
  raise ArgumentError, "Target must be a hash" unless target.is_a?(Hash)
26
27
 
27
28
  unless dimensions.is_a?(Array) && dimensions.any? && dimensions.uniq == dimensions &&
@@ -29,19 +30,18 @@ module Wireless
29
30
  ap_names.all? { |name| name.is_a?(String) && !name.strip.empty? && !/[\x00-\x1f\x7f]/.match?(name) }
30
31
  raise ArgumentError, "Invalid wireless dimensions or AP selection"
31
32
  end
33
+ # 外部连接和适配器回调前保存身份与选择范围。
34
+ target = CtxParser::Immutable.copy(target)
35
+ ap_names = ap_names.map { |name| name.dup.freeze }.freeze
32
36
  requested = dimensions.dup
33
37
  # LLDP 需要 AP 清单提供身份;组和服务模板需要配置证据补充关联关系。
34
38
  requested |= [ :access_points ] if requested.include?(:lldp)
35
39
  requested |= [ :configuration ] if (requested & %i[ap_groups service_templates]).any?
36
- profile = ParserRegistry.fetch(platform, model: model) unless platform == "huawei"
37
- commands = profile ? profile.commands.except(:lldp) : HUAWEI_COMMANDS
38
- observations = requested.reject { |name| commands.key?(name) || (name == :lldp && platform != "huawei") }
40
+ profile = ParserRegistry.fetch(platform, model: model)
41
+ commands = profile.commands.except(:lldp)
42
+ observations = requested.reject { |name| commands.key?(name) || (name == :lldp && profile.commands.key?(:lldp)) }
39
43
  .to_h { |name| [ name, unavailable(name, :unsupported, :unsupported_platform_dimension) ] }
40
- queries = if profile
41
- profile.queries(requested & commands.keys)
42
- else
43
- commands.slice(*requested).group_by { |_name, command| command }.transform_values { |entries| entries.map(&:first) }
44
- end
44
+ queries = profile.queries(requested & commands.keys)
45
45
  actual = connector.platform.to_s
46
46
  unless actual == platform || ([ actual, platform ] - %w[h3c h3c_wireless]).empty?
47
47
  raise ArgumentError, "Wireless platform differs from the device connector"
@@ -51,7 +51,7 @@ module Wireless
51
51
  connector.open do |session|
52
52
  reader = CtxDriver::Reader.new(session: session, clock: @clock)
53
53
  stage = :prepare
54
- preparation = reader.read(platform == "huawei" ? "screen-length 0 temporary" : "screen-length disable")
54
+ preparation = reader.read(profile.preparation_command)
55
55
  error = preparation.failure&.fetch(:code)
56
56
  queries.each do |command, names|
57
57
  if error
@@ -59,16 +59,14 @@ module Wireless
59
59
  next
60
60
  end
61
61
  stage = :collect
62
- observations.merge!(read(reader, command, names, platform, model: model))
62
+ observations.merge!(collect_command(reader, command, names, platform, model: model))
63
63
  end
64
- if requested.include?(:lldp) && platform != "huawei"
64
+ if requested.include?(:lldp) && profile.commands.key?(:lldp)
65
65
  unavailable_code = error || reader.failure&.fetch(:code)
66
66
  observations[:lldp] = if unavailable_code
67
67
  unavailable(:lldp, :failed, unavailable_code, skipped: true, stage: stage)
68
68
  else
69
- # 仅在显式请求 LLDP 且会话准备成功后读取 AP 凭据,避免无关采集访问秘密。
70
- credentials = (ap_connection.respond_to?(:call) ? ap_connection.call : ap_connection) || {}
71
- neighbors(session, observations[:access_points], credentials, ap_names)
69
+ collect_ap_neighbors(session, observations[:access_points], ap_connection, ap_names)
72
70
  end
73
71
  end
74
72
  stage = :close
@@ -88,7 +86,7 @@ module Wireless
88
86
  private
89
87
 
90
88
  # 单条命令负责自身解析失败;无关查询保留已完成事实。
91
- def read(reader, command, names, platform, model:)
89
+ def collect_command(reader, command, names, platform, model:)
92
90
  capture = reader.read(command)
93
91
  metadata = { started_at: capture.started_at, completed_at: capture.completed_at, evidence: capture.evidence }
94
92
  unless capture.success?
@@ -111,47 +109,62 @@ module Wireless
111
109
  end
112
110
 
113
111
  # 独立采集 AP 邻居证据,并报告 AP 覆盖不完整的情况。
114
- def neighbors(session, aps, credentials, names)
112
+ def collect_ap_neighbors(session, aps, connection, names)
115
113
  return unavailable(:lldp, :failed, :access_points_unavailable) unless aps&.available?
116
- return unavailable(:lldp, :failed, :ap_credentials_required) unless credentials[:password].is_a?(String) && !credentials[:password].strip.empty?
117
- ap_session = ApSession.new(controller: session, **credentials)
118
114
  started = @clock.call
119
115
  records, issues, evidence = [], [], []
120
- selected = aps.records.select { |ap| names.empty? || names.include?(ap[:name]) }
121
- # 所选 AP 全部查询成功也不代表覆盖整台控制器,过滤范围和父清单缺失须独立报告。
116
+ selected_names = names.to_h { |name| [ name, true ] }
117
+ selected = aps.records.select { |ap| selected_names.empty? || selected_names.key?(ap[:name]) }
118
+ # 空清单和未匹配的选择不需要登录 AP,也不能因此访问凭据。
122
119
  issues << { code: :partial_access_point_coverage } unless aps.complete?
123
120
  issues << { code: :ap_scope_filtered } if selected.size != aps.records.size
124
121
  (names - selected.map { |ap| ap[:name] }).each { |name| issues << { code: :ap_not_found, ap_name: name } }
125
- selected.each do |ap|
126
- collected = read_neighbor(ap_session, ap)
127
- records.concat(collected.fetch(:records))
128
- issues.concat(collected.fetch(:issues))
129
- evidence << collected.fetch(:evidence)
122
+ if selected.any?
123
+ return unavailable(:lldp, :failed, :ap_session_required) unless @ap_session
124
+ credentials = (connection.respond_to?(:call) ? connection.call : connection) || {}
125
+ return unavailable(:lldp, :failed, :invalid_ap_connection) unless credentials.is_a?(Hash)
126
+ ap_session = @ap_session.call(**credentials, controller: session)
127
+ return unavailable(:lldp, :failed, :invalid_ap_session) unless ap_session.respond_to?(:read)
128
+ selected.each do |ap|
129
+ collected = read_ap_neighbors(ap_session, ap)
130
+ records.concat(collected.fetch(:records))
131
+ issues.concat(collected.fetch(:issues))
132
+ evidence << collected.fetch(:evidence)
133
+ end
130
134
  end
131
135
  status = issues.empty? ? :complete : records.any? || evidence.any? { |entry| entry[:status] == :complete } ? :partial : :failed
132
136
  Observation.new(name: :lldp, status: status, records: records, issues: issues, evidence: evidence,
133
137
  started_at: started, completed_at: @clock.call)
134
138
  rescue ArgumentError
135
139
  unavailable(:lldp, :failed, :invalid_ap_credentials)
140
+ rescue StandardError => error
141
+ details = CtxDriver::Failure.details(error, fallback: :ap_session_setup_failed, stage: :ap_setup)
142
+ unavailable(:lldp, :failed, details.fetch(:code), **details.except(:code))
136
143
  end
137
144
 
138
145
  # 单个 AP 脚本或解析失败不能丢弃其他 AP 的邻居观测。
139
- def read_neighbor(ap_session, ap)
146
+ def read_ap_neighbors(ap_session, ap)
140
147
  stage = :execute
141
- source = { kind: :ap_lldp_result, ap_name: ap[:name], command: ApSession::COMMAND,
148
+ source = { kind: :ap_lldp_result, ap_name: ap[:name], command: H3c::Profile::COMMANDS.fetch(:lldp),
142
149
  attempted: true, status: :failed, neighbor_count: 0 }
143
150
  outcome = ap_session.read(access_point: ap)
144
151
  result = outcome[:result]
145
152
  stage = :response
146
153
  source[:attempted] = !outcome[:skipped]
147
- source.merge!(result.evidence.first || {}) if result
154
+ if result
155
+ unless result.is_a?(CtxDriver::Capture) && result.command == source[:command] && !(outcome[:skipped] && result.success?)
156
+ raise TypeError, "AP capture must match the requested LLDP command"
157
+ end
158
+ # 回执补充摘要与时间;AP 身份、命令和尝试状态始终由本次请求确定。
159
+ source = (result.evidence.first || {}).merge(source)
160
+ end
148
161
  stage = :parse
149
162
  parsed = result&.success? ? NeighborParser.new(model: ap[:model]).parse(result.body, ap_name: ap[:name], complete: true) : nil
150
163
  issues = parsed ? parsed.issues.map { |issue| issue.merge(ap_name: ap[:name]) } : []
151
- %i[error cleanup_error].each { |key| issues << { code: outcome[key], ap_name: ap[:name] } if outcome[key] }
164
+ %i[error cleanup_error close_error].each { |key| issues << { code: outcome[key], ap_name: ap[:name] } if outcome[key] }
152
165
  issues << { code: :ap_collection_failed, ap_name: ap[:name] } if !parsed && issues.empty?
153
166
  source.merge!(
154
- status: outcome[:error] || outcome[:cleanup_error] ? :failed : parsed&.status || :failed,
167
+ status: outcome[:error] || outcome[:cleanup_error] || outcome[:close_error] ? :failed : parsed&.status || :failed,
155
168
  neighbor_count: parsed&.records&.size || 0)
156
169
  { records: parsed&.records || [], issues: issues, evidence: source }
157
170
  rescue StandardError => error