keenetic 0.1.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.
@@ -0,0 +1,267 @@
1
+ module Keenetic
2
+ module Resources
3
+ # System resource for accessing router system information and status.
4
+ #
5
+ # == API Endpoints Used
6
+ #
7
+ # === Reading System Status
8
+ # GET /rci/show/system
9
+ # Returns: { cpuload, memtotal, memfree, membuffers, memcache, swaptotal, swapfree, uptime }
10
+ #
11
+ # === Reading System Info
12
+ # GET /rci/show/version
13
+ # Returns: { model, device, manufacturer, hw_version, release, ndm, ndw, ... }
14
+ #
15
+ # === Reading Defaults
16
+ # GET /rci/show/defaults
17
+ # Returns: Default configuration values (system-name, domain-name, etc.)
18
+ #
19
+ # === Reading License
20
+ # GET /rci/show/license
21
+ # Returns: { valid, active, expires, type, features, services }
22
+ #
23
+ class System < Base
24
+ # Get system resource usage (CPU, memory, swap).
25
+ #
26
+ # == Keenetic API Request
27
+ # GET /rci/show/system
28
+ #
29
+ # == Response Fields from API
30
+ # - cpuload: CPU usage percentage (0-100)
31
+ # - memtotal: Total RAM in bytes
32
+ # - memfree: Free RAM in bytes
33
+ # - membuffers: Buffer memory in bytes
34
+ # - memcache: Cached memory in bytes
35
+ # - swaptotal: Total swap in bytes
36
+ # - swapfree: Free swap in bytes
37
+ # - uptime: System uptime in seconds
38
+ #
39
+ # @return [Hash] Normalized resource data with :cpu, :memory, :swap, :uptime
40
+ # @example
41
+ # resources = client.system.resources
42
+ # # => { cpu: { load_percent: 15 },
43
+ # # memory: { total: 536870912, free: 268435456, used: 215789568, used_percent: 40.2 },
44
+ # # swap: { total: 0, free: 0, used: 0, used_percent: 0 },
45
+ # # uptime: 86400 }
46
+ #
47
+ def resources
48
+ response = get('/rci/show/system')
49
+ normalize_resources(response)
50
+ end
51
+
52
+ # Get router system information (model, firmware, hardware).
53
+ #
54
+ # == Keenetic API Request
55
+ # GET /rci/show/version
56
+ #
57
+ # == Response Fields from API
58
+ # - model: Router model name (e.g., "Keenetic Viva")
59
+ # - device: Device code (e.g., "KN-1912")
60
+ # - manufacturer: "Keenetic Ltd."
61
+ # - vendor: "Keenetic"
62
+ # - hw_version: Hardware revision
63
+ # - title: Firmware title
64
+ # - release: Firmware version string
65
+ # - ndm: { version, exact } - NDM version info
66
+ # - ndw: { version } - NDW version info
67
+ # - arch: CPU architecture (e.g., "mips", "aarch64")
68
+ # - components: Array of installed components
69
+ #
70
+ # @return [Hash] Normalized system info
71
+ # @example
72
+ # info = client.system.info
73
+ # # => { model: "Keenetic Viva", device: "KN-1912", firmware_version: "4.01.C.7.0-0", ... }
74
+ #
75
+ def info
76
+ response = get('/rci/show/version')
77
+ normalize_info(response)
78
+ end
79
+
80
+ # Get system uptime in seconds.
81
+ #
82
+ # == Keenetic API Request
83
+ # GET /rci/show/system
84
+ #
85
+ # @return [Integer] Uptime in seconds
86
+ # @example
87
+ # client.system.uptime
88
+ # # => 86400 (1 day)
89
+ #
90
+ def uptime
91
+ response = get('/rci/show/system')
92
+ response['uptime'] if response.is_a?(Hash)
93
+ end
94
+
95
+ # Get default system configuration values.
96
+ #
97
+ # == Keenetic API Request
98
+ # GET /rci/show/defaults
99
+ #
100
+ # == Response Fields from API (examples)
101
+ # - system-name: Default router name
102
+ # - domain-name: Default domain
103
+ # - language: System language
104
+ # - ntp-server: Default NTP server
105
+ #
106
+ # @return [Hash] Default configuration with snake_case keys
107
+ # @example
108
+ # defaults = client.system.defaults
109
+ # # => { system_name: "Keenetic", domain_name: "local", language: "en", ... }
110
+ #
111
+ def defaults
112
+ response = get('/rci/show/defaults')
113
+ normalize_defaults(response)
114
+ end
115
+
116
+ # Get license status and enabled features.
117
+ #
118
+ # == Keenetic API Request
119
+ # GET /rci/show/license
120
+ #
121
+ # == Response Fields from API
122
+ # - valid: Boolean - license is valid
123
+ # - active: Boolean - license is active
124
+ # - expires: Expiration date string
125
+ # - type: License type (e.g., "standard")
126
+ # - features: Array of enabled features
127
+ # - services: Array of service statuses
128
+ #
129
+ # @return [Hash] License information
130
+ # @example
131
+ # license = client.system.license
132
+ # # => { valid: true, active: true, expires: "2025-12-31",
133
+ # # features: [{ name: "vpn-server", enabled: true }],
134
+ # # services: [{ name: "keendns", enabled: true, active: true }] }
135
+ #
136
+ def license
137
+ response = get('/rci/show/license')
138
+ normalize_license(response)
139
+ end
140
+
141
+ private
142
+
143
+ def normalize_resources(response)
144
+ return {} unless response.is_a?(Hash)
145
+
146
+ {
147
+ cpu: normalize_cpu(response['cpuload']),
148
+ memory: normalize_memory(response),
149
+ swap: normalize_swap(response),
150
+ uptime: response['uptime']
151
+ }
152
+ end
153
+
154
+ def normalize_cpu(cpuload)
155
+ return nil unless cpuload
156
+
157
+ {
158
+ load_percent: cpuload.to_i
159
+ }
160
+ end
161
+
162
+ def normalize_memory(response)
163
+ total = response['memtotal']
164
+ free = response['memfree']
165
+ buffers = response['membuffers'] || 0
166
+ cached = response['memcache'] || 0
167
+
168
+ return nil unless total && free
169
+
170
+ used = total - free - buffers - cached
171
+
172
+ {
173
+ total: total,
174
+ free: free,
175
+ used: used,
176
+ buffers: buffers,
177
+ cached: cached,
178
+ used_percent: ((used.to_f / total) * 100).round(1)
179
+ }
180
+ end
181
+
182
+ def normalize_swap(response)
183
+ total = response['swaptotal']
184
+ free = response['swapfree']
185
+
186
+ return nil unless total && free && total > 0
187
+
188
+ used = total - free
189
+
190
+ {
191
+ total: total,
192
+ free: free,
193
+ used: used,
194
+ used_percent: ((used.to_f / total) * 100).round(1)
195
+ }
196
+ end
197
+
198
+ def normalize_info(response)
199
+ return {} unless response.is_a?(Hash)
200
+
201
+ {
202
+ model: response['model'],
203
+ device: response['device'],
204
+ manufacturer: response['manufacturer'],
205
+ vendor: response['vendor'],
206
+ hw_version: response['hw_version'],
207
+ hw_id: response['hw_id'],
208
+ firmware: response['title'],
209
+ firmware_version: response['release'],
210
+ ndm_version: response.dig('ndm', 'exact') || response.dig('ndm', 'version'),
211
+ arch: response['arch'],
212
+ ndw_version: response.dig('ndw', 'version'),
213
+ components: response['components'],
214
+ sandbox: response['sandbox']
215
+ }
216
+ end
217
+
218
+ def normalize_defaults(response)
219
+ return {} unless response.is_a?(Hash)
220
+
221
+ deep_normalize_keys(response)
222
+ end
223
+
224
+ def normalize_license(response)
225
+ return {} unless response.is_a?(Hash)
226
+
227
+ result = {
228
+ valid: normalize_boolean(response['valid']),
229
+ active: normalize_boolean(response['active']),
230
+ expires: response['expires'],
231
+ type: response['type'],
232
+ features: normalize_features(response['features']),
233
+ services: normalize_services(response['services'])
234
+ }
235
+
236
+ # Remove nil values for cleaner response
237
+ result.compact
238
+ end
239
+
240
+ def normalize_features(features)
241
+ return [] unless features.is_a?(Array)
242
+
243
+ features.map do |feature|
244
+ if feature.is_a?(Hash)
245
+ normalize_keys(feature)
246
+ else
247
+ feature
248
+ end
249
+ end
250
+ end
251
+
252
+ def normalize_services(services)
253
+ return [] unless services.is_a?(Array)
254
+
255
+ services.map do |service|
256
+ if service.is_a?(Hash)
257
+ result = normalize_keys(service)
258
+ normalize_booleans(result, %i[enabled active])
259
+ else
260
+ service
261
+ end
262
+ end
263
+ end
264
+ end
265
+ end
266
+ end
267
+
@@ -0,0 +1,376 @@
1
+ module Keenetic
2
+ module Resources
3
+ # Wi-Fi resource for accessing wireless network information.
4
+ #
5
+ # == API Endpoints Used
6
+ #
7
+ # === Reading Wi-Fi Access Points
8
+ # GET /rci/show/interface
9
+ # Filters: interfaces where type == "AccessPoint" or id starts with "WifiMaster"
10
+ # Returns Wi-Fi specific fields: ssid, channel, band, authentication, encryption, station-count
11
+ #
12
+ # === Reading Connected Clients (Associations)
13
+ # GET /rci/show/associations
14
+ # Returns: { "station": [...] } - array of connected Wi-Fi clients
15
+ # Station fields: mac, ap, rssi, txrate, rxrate, uptime, ht/vht/he mode flags
16
+ #
17
+ # == Wi-Fi Interface Naming
18
+ # - WifiMaster0: First Wi-Fi radio (usually 2.4GHz)
19
+ # - WifiMaster1: Second Wi-Fi radio (usually 5GHz)
20
+ # - WifiMaster0/AccessPoint0: Main SSID on first radio
21
+ # - WifiMaster0/AccessPoint1: Guest SSID on first radio
22
+ #
23
+ class WiFi < Base
24
+ # Get all Wi-Fi access points.
25
+ #
26
+ # == Keenetic API Request
27
+ # GET /rci/show/interface
28
+ # Internally filters for Wi-Fi interfaces only
29
+ #
30
+ # == Wi-Fi Specific Fields from API
31
+ # - ssid: Network name
32
+ # - channel: Wi-Fi channel number
33
+ # - band: Frequency band ("2.4GHz", "5GHz")
34
+ # - authentication: Security mode (wpa2-psk, wpa3-psk, etc.)
35
+ # - encryption: Encryption type (aes, tkip)
36
+ # - station-count: Number of connected clients
37
+ # - txpower: Transmit power in dBm
38
+ #
39
+ # @return [Array<Hash>] List of Wi-Fi access points
40
+ # @example
41
+ # aps = client.wifi.access_points
42
+ # # => [{ id: "WifiMaster0/AccessPoint0", ssid: "MyNetwork", channel: 6, band: "2.4GHz", ... }]
43
+ #
44
+ def access_points
45
+ response = get('/rci/show/interface')
46
+ extract_wifi_interfaces(response)
47
+ end
48
+
49
+ # Get Mesh Wi-Fi System members (controller + extenders).
50
+ #
51
+ # == Keenetic API Request
52
+ # POST /rci/
53
+ # Body: [{"show":{"mws":{"member":{}}}}, {"show":{"mws":{"status":{}}}}]
54
+ #
55
+ # == Response Structure from API
56
+ # Member data includes:
57
+ # - known: true/false (if node is registered)
58
+ # - online: true/false
59
+ # - cid: unique controller ID
60
+ # - mac: device MAC address
61
+ # - hw-id: hardware ID
62
+ # - hw-version: hardware version
63
+ # - model: device model (e.g., "KN-4010", "KN-1613")
64
+ # - name: device name
65
+ # - mode: operating mode ("controller", "extender")
66
+ # - via: connection path (e.g., "Ethernet", "WifiMaster1")
67
+ # - ip: device IP address
68
+ # - uptime: uptime in seconds
69
+ # - version: firmware version
70
+ #
71
+ # @return [Array<Hash>] List of mesh nodes (controller + extenders)
72
+ # @example
73
+ # nodes = client.wifi.mesh_members
74
+ # # => [{ id: "abc123", name: "Main Router", mode: "controller", ... }]
75
+ #
76
+ def mesh_members
77
+ responses = client.batch([
78
+ { 'show' => { 'mws' => { 'member' => {} } } },
79
+ { 'show' => { 'version' => {} } },
80
+ { 'show' => { 'system' => {} } },
81
+ { 'show' => { 'associations' => {} } }
82
+ ])
83
+
84
+ members_response = responses[0] || {}
85
+ version_response = responses[1] || {}
86
+ system_response = responses[2] || {}
87
+ associations_response = responses[3] || {}
88
+
89
+ # Get extenders from mws member
90
+ extenders = normalize_mesh_members(members_response)
91
+
92
+ # Build controller from version/system info
93
+ controller = build_controller(version_response, system_response, associations_response)
94
+
95
+ # Return controller first, then extenders
96
+ [controller, *extenders].compact
97
+ end
98
+
99
+ # Get connected Wi-Fi clients (associations).
100
+ #
101
+ # == Keenetic API Request
102
+ # GET /rci/show/associations
103
+ #
104
+ # == Response Structure from API
105
+ # {
106
+ # "station": [
107
+ # {
108
+ # "mac": "AA:BB:CC:DD:EE:FF",
109
+ # "ap": "WifiMaster0/AccessPoint0",
110
+ # "authenticated": true,
111
+ # "txrate": 866700,
112
+ # "rxrate": 780000,
113
+ # "rssi": -45,
114
+ # "uptime": 3600,
115
+ # "mcs": 9,
116
+ # "ht": false,
117
+ # "vht": true,
118
+ # "mode": "ac",
119
+ # "gi": "short"
120
+ # }
121
+ # ]
122
+ # }
123
+ #
124
+ # == Signal Strength (RSSI)
125
+ # - rssi: Signal strength in dBm (negative value, closer to 0 is stronger)
126
+ # - Typical ranges: -30 to -50 (excellent), -50 to -70 (good), -70 to -80 (fair)
127
+ #
128
+ # @return [Array<Hash>] List of connected Wi-Fi clients
129
+ # @example
130
+ # clients = client.wifi.clients
131
+ # # => [{ mac: "AA:BB:CC:DD:EE:FF", ap: "WifiMaster0/AccessPoint0", rssi: -45, ... }]
132
+ #
133
+ def clients
134
+ response = get('/rci/show/associations')
135
+ normalize_clients(response)
136
+ end
137
+
138
+ # Get specific Wi-Fi access point by ID.
139
+ #
140
+ # @param id [String] Access point ID (e.g., "WifiMaster0/AccessPoint0")
141
+ # @return [Hash, nil] Access point data or nil if not found
142
+ # @example
143
+ # ap = client.wifi.access_point('WifiMaster0/AccessPoint0')
144
+ # # => { id: "WifiMaster0/AccessPoint0", ssid: "MyNetwork", ... }
145
+ #
146
+ def access_point(id)
147
+ access_points.find { |ap| ap[:id] == id }
148
+ end
149
+
150
+ # Configure Wi-Fi access point settings.
151
+ #
152
+ # == Keenetic API Request
153
+ # POST /rci/ (batch format)
154
+ # Body: [{"interface": {"<ap_id>": {<config>}}}]
155
+ #
156
+ # == Authentication Types
157
+ # - "open": No security
158
+ # - "wpa-psk": WPA Personal
159
+ # - "wpa2-psk": WPA2 Personal
160
+ # - "wpa3-psk": WPA3 Personal
161
+ # - "wpa2/wpa3-psk": WPA2/WPA3 mixed mode
162
+ #
163
+ # == Encryption Types
164
+ # - "aes": AES encryption (recommended)
165
+ # - "tkip": TKIP encryption (legacy)
166
+ #
167
+ # @param id [String] Access point ID (e.g., "WifiMaster0/AccessPoint0")
168
+ # @param options [Hash] Configuration options
169
+ # @option options [String] :ssid Network name
170
+ # @option options [String] :authentication Security mode
171
+ # @option options [String] :encryption Encryption type
172
+ # @option options [String] :key Wi-Fi password
173
+ # @option options [Integer] :channel Channel number (0 for auto)
174
+ # @option options [Boolean] :up Enable or disable the access point
175
+ # @return [Array<Hash>] API response
176
+ #
177
+ # @example Configure access point
178
+ # client.wifi.configure('WifiMaster0/AccessPoint0',
179
+ # ssid: 'MyNetwork',
180
+ # authentication: 'wpa2-psk',
181
+ # encryption: 'aes',
182
+ # key: 'mysecretpassword',
183
+ # up: true
184
+ # )
185
+ # # Sends: [{"interface":{"WifiMaster0/AccessPoint0":{"ssid":"MyNetwork",...}}}]
186
+ #
187
+ # @example Change SSID only
188
+ # client.wifi.configure('WifiMaster0/AccessPoint0', ssid: 'NewNetworkName')
189
+ #
190
+ # @example Set channel
191
+ # client.wifi.configure('WifiMaster0', channel: 6)
192
+ #
193
+ def configure(id, **options)
194
+ params = {}
195
+
196
+ params['ssid'] = options[:ssid] if options[:ssid]
197
+ params['authentication'] = options[:authentication] if options[:authentication]
198
+ params['encryption'] = options[:encryption] if options[:encryption]
199
+ params['key'] = options[:key] if options[:key]
200
+ params['channel'] = options[:channel] if options[:channel]
201
+ params['up'] = options[:up] unless options[:up].nil?
202
+
203
+ return {} if params.empty?
204
+
205
+ client.batch([{ 'interface' => { id => params } }])
206
+ end
207
+
208
+ # Enable a Wi-Fi access point.
209
+ #
210
+ # == Keenetic API Request
211
+ # POST /rci/ (batch format)
212
+ # Body: [{"interface": {"<ap_id>": {"up": true}}}]
213
+ #
214
+ # @param id [String] Access point ID (e.g., "WifiMaster0/AccessPoint0")
215
+ # @return [Array<Hash>] API response
216
+ #
217
+ # @example Enable access point
218
+ # client.wifi.enable('WifiMaster0/AccessPoint0')
219
+ # # Sends: [{"interface":{"WifiMaster0/AccessPoint0":{"up":true}}}]
220
+ #
221
+ def enable(id)
222
+ client.batch([{ 'interface' => { id => { 'up' => true } } }])
223
+ end
224
+
225
+ # Disable a Wi-Fi access point.
226
+ #
227
+ # == Keenetic API Request
228
+ # POST /rci/ (batch format)
229
+ # Body: [{"interface": {"<ap_id>": {"up": false}}}]
230
+ #
231
+ # @param id [String] Access point ID (e.g., "WifiMaster0/AccessPoint0")
232
+ # @return [Array<Hash>] API response
233
+ #
234
+ # @example Disable guest network
235
+ # client.wifi.disable('WifiMaster0/AccessPoint1')
236
+ # # Sends: [{"interface":{"WifiMaster0/AccessPoint1":{"up":false}}}]
237
+ #
238
+ def disable(id)
239
+ client.batch([{ 'interface' => { id => { 'up' => false } } }])
240
+ end
241
+
242
+ private
243
+
244
+ def extract_wifi_interfaces(response)
245
+ return [] unless response.is_a?(Hash)
246
+
247
+ response
248
+ .select { |id, data| wifi_interface?(id, data) }
249
+ .map { |id, data| normalize_wifi(id, data) }
250
+ end
251
+
252
+ def wifi_interface?(id, data)
253
+ return false unless data.is_a?(Hash)
254
+
255
+ data['type'] == 'AccessPoint' ||
256
+ data['type'] == 'WifiMaster' ||
257
+ id.start_with?('WifiMaster') ||
258
+ id.start_with?('AccessPoint')
259
+ end
260
+
261
+ def normalize_wifi(id, data)
262
+ {
263
+ id: id,
264
+ description: data['description'],
265
+ type: data['type'],
266
+ ssid: data['ssid'],
267
+ mac: data['mac'],
268
+ state: data['state'],
269
+ link: data['link'],
270
+ connected: data['connected'],
271
+ channel: data['channel'],
272
+ band: data['band'],
273
+ security: data['authentication'],
274
+ encryption: data['encryption'],
275
+ clients_count: data['station-count'],
276
+ txpower: data['txpower'],
277
+ uptime: data['uptime']
278
+ }
279
+ end
280
+
281
+ def normalize_clients(response)
282
+ return [] unless response.is_a?(Hash) && response['station']
283
+
284
+ stations = response['station']
285
+ stations = [stations] unless stations.is_a?(Array)
286
+
287
+ stations.map { |station| normalize_client(station) }
288
+ end
289
+
290
+ def normalize_client(station)
291
+ {
292
+ mac: station['mac'],
293
+ ap: station['ap'],
294
+ authenticated: station['authenticated'],
295
+ txrate: station['txrate'],
296
+ rxrate: station['rxrate'],
297
+ uptime: station['uptime'],
298
+ txbytes: station['txbytes'],
299
+ rxbytes: station['rxbytes'],
300
+ rssi: station['rssi'],
301
+ mcs: station['mcs'],
302
+ ht: station['ht'],
303
+ mode: station['mode'],
304
+ gi: station['gi']
305
+ }
306
+ end
307
+
308
+ def normalize_mesh_members(members_response)
309
+ # Response is nested: { "show" => { "mws" => { "member" => [...] } } }
310
+ members_data = members_response.dig('show', 'mws', 'member') if members_response.is_a?(Hash)
311
+ return [] unless members_data
312
+
313
+ members = members_data.is_a?(Array) ? members_data : [members_data]
314
+
315
+ members.filter_map { |member| normalize_mesh_member(member) }
316
+ end
317
+
318
+ def build_controller(version_response, system_response, associations_response)
319
+ version = version_response.dig('show', 'version') || {}
320
+ system = system_response.dig('show', 'system') || {}
321
+
322
+ # Count associations (wifi clients connected to controller)
323
+ stations = associations_response.dig('show', 'associations', 'station') || []
324
+ stations = [stations] unless stations.is_a?(Array)
325
+ client_count = stations.size
326
+
327
+ return nil if version.empty?
328
+
329
+ {
330
+ id: 'controller',
331
+ mac: version['mac'],
332
+ name: system['name'] || version['description'] || version['model'],
333
+ model: "#{version['model']} (#{version['hw_id']})",
334
+ hw_id: version['hw_id'],
335
+ hw_version: version['hw_version'],
336
+ mode: 'controller',
337
+ via: nil, # Controller doesn't have upstream
338
+ ip: nil, # Controller is the gateway
339
+ version: version['release'],
340
+ online: true,
341
+ uptime: system['uptime'],
342
+ clients_count: client_count,
343
+ connection_speed: nil,
344
+ alert: false
345
+ }
346
+ end
347
+
348
+ def normalize_mesh_member(member)
349
+ return nil unless member.is_a?(Hash)
350
+
351
+ cid = member['cid']
352
+ system_info = member['system'] || {}
353
+ backhaul_info = member['backhaul'] || {}
354
+
355
+ {
356
+ id: cid,
357
+ mac: member['mac'],
358
+ name: member['known-host'] || member['model'],
359
+ model: member['model'],
360
+ hw_id: member['hw_id'] || member['hw-id'],
361
+ hw_version: member['hw-version'],
362
+ mode: member['mode'], # "controller" or "extender"
363
+ via: backhaul_info['uplink'], # Connection method (FastEthernet0/Vlan1, WifiMaster1, etc.)
364
+ ip: member['ip'],
365
+ version: member['fw'],
366
+ online: true, # Members in the list are online
367
+ uptime: system_info['uptime']&.to_i,
368
+ clients_count: member['associations'],
369
+ connection_speed: backhaul_info['speed'],
370
+ alert: false
371
+ }
372
+ end
373
+ end
374
+ end
375
+ end
376
+
@@ -0,0 +1,4 @@
1
+ module Keenetic
2
+ VERSION = '0.1.0'.freeze
3
+ end
4
+