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,309 @@
1
+ module Keenetic
2
+ module Resources
3
+ # Manages network devices (hosts) connected to the router.
4
+ #
5
+ # == API Endpoints Used
6
+ #
7
+ # === Reading Devices
8
+ # GET /rci/show/ip/hotspot
9
+ # Returns: { "host": [...] } - array of registered devices
10
+ #
11
+ # === Writing Device Properties
12
+ # Device updates use different RCI commands depending on the property:
13
+ #
14
+ # - **Name**: Set via `known.host` command
15
+ # POST /rci/ with [{"known":{"host":{"mac":"...","name":"..."}}}]
16
+ #
17
+ # - **Access Policy**: Set via `ip.hotspot.host` command
18
+ # POST /rci/ with [{"ip":{"hotspot":{"host":{"mac":"...","permit":true}}}}]
19
+ #
20
+ # - **Delete**: Remove via `ip.hotspot.host` with `no` flag
21
+ # POST /rci/ with [{"ip":{"hotspot":{"host":{"mac":"...","no":true}}}}]
22
+ #
23
+ # == MAC Address Format
24
+ # - Reading: API returns uppercase (e.g., "AA:BB:CC:DD:EE:FF")
25
+ # - Writing: Must use lowercase (e.g., "aa:bb:cc:dd:ee:ff")
26
+ # - The gem handles this conversion automatically
27
+ #
28
+ # == Device Fields from API
29
+ # - mac: MAC address (uppercase, colon-separated)
30
+ # - name: User-assigned device name
31
+ # - hostname: Device-reported hostname
32
+ # - ip: Current IP address
33
+ # - interface: Connected interface ID (e.g., "Bridge0")
34
+ # - via: Connection path (e.g., "WifiMaster0/AccessPoint0")
35
+ # - active: Boolean - currently connected
36
+ # - registered: Boolean - registered device
37
+ # - access: "permit" or "deny"
38
+ # - schedule: Schedule name for access control
39
+ # - rxbytes/txbytes: Traffic counters
40
+ # - uptime: Current session uptime in seconds
41
+ # - first-seen/last-seen: Timestamps
42
+ #
43
+ class Devices < Base
44
+ # Fetch all registered devices (hosts) with static IP info and Wi-Fi association data.
45
+ #
46
+ # == Keenetic API Request
47
+ # GET /rci/show/ip/hotspot/host - device list
48
+ # GET /rci/show/associations - Wi-Fi client data (rssi, txrate, rxrate, mode)
49
+ #
50
+ # Static IP info is included in the device data as dhcp.static: true
51
+ # When static is true, the device's ip field is the reserved static IP.
52
+ #
53
+ # For Wi-Fi connected devices, association data (signal strength, speed) is merged.
54
+ #
55
+ # @return [Array<Hash>] List of normalized device hashes with static_ip and Wi-Fi info
56
+ # @example
57
+ # devices = client.devices.all
58
+ # # => [{ mac: "AA:BB:CC:DD:EE:FF", name: "My Phone", active: true, static_ip: "192.168.1.100", rssi: -45, ... }]
59
+ #
60
+ def all
61
+ # Fetch devices and associations
62
+ hosts_response = get('/rci/show/ip/hotspot/host')
63
+ associations_response = get('/rci/show/associations')
64
+
65
+ # Build MAC -> association lookup
66
+ associations_by_mac = build_associations_lookup(associations_response || {})
67
+
68
+ normalize_devices(hosts_response, associations_by_mac)
69
+ end
70
+
71
+ # Find a specific device by MAC address.
72
+ #
73
+ # Uses #all internally and filters by MAC (case-insensitive comparison).
74
+ #
75
+ # @param mac [String] Device MAC address (case-insensitive)
76
+ # @return [Hash] Device data with static_ip info
77
+ # @raise [NotFoundError] if device not found
78
+ # @example
79
+ # device = client.devices.find(mac: 'AA:BB:CC:DD:EE:FF')
80
+ # # => { mac: "AA:BB:CC:DD:EE:FF", name: "My Phone", static_ip: "192.168.1.100", ... }
81
+ #
82
+ def find(mac:)
83
+ devices = all
84
+ device = devices.find { |d| d[:mac]&.downcase == mac.downcase }
85
+ raise NotFoundError, "Device with MAC #{mac} not found" unless device
86
+ device
87
+ end
88
+
89
+ # Update device properties (name, access policy, schedule).
90
+ #
91
+ # == Keenetic API Requests
92
+ # Multiple commands may be sent depending on which properties are updated:
93
+ #
94
+ # === Setting Device Name
95
+ # POST /rci/ (batch format)
96
+ # Body: [{"known":{"host":{"mac":"aa:bb:cc:dd:ee:ff","name":"Living Room TV"}}}]
97
+ #
98
+ # === Setting Access Policy
99
+ # POST /rci/ (batch format)
100
+ # Body: [{"ip":{"hotspot":{"host":{"mac":"aa:bb:cc:dd:ee:ff","permit":true}}}}]
101
+ # Or: [{"ip":{"hotspot":{"host":{"mac":"aa:bb:cc:dd:ee:ff","deny":true}}}}]
102
+ #
103
+ # === Setting Schedule
104
+ # POST /rci/ (batch format)
105
+ # Body: [{"ip":{"hotspot":{"host":{"mac":"aa:bb:cc:dd:ee:ff","schedule":"night"}}}}]
106
+ #
107
+ # == MAC Address
108
+ # The MAC is automatically converted to lowercase for the API.
109
+ #
110
+ # @param mac [String] Device MAC address (case-insensitive)
111
+ # @param name [String] New device name (optional)
112
+ # @param access [String] Access policy: "permit" or "deny" (optional)
113
+ # @param schedule [String] Schedule name for access control (optional)
114
+ # @param policy [String, nil] Routing policy ID (e.g., "Policy0") or nil/empty to remove (optional)
115
+ # @param static_ip [String, nil] Static IP address to reserve, or nil/empty to remove (optional)
116
+ # @return [Array<Hash>] API response array, or {} if no attributes provided
117
+ #
118
+ # @example Update device name
119
+ # client.devices.update(mac: "AA:BB:CC:DD:EE:FF", name: "Living Room TV")
120
+ # # Sends: [{"known":{"host":{"mac":"aa:bb:cc:dd:ee:ff","name":"Living Room TV"}}}]
121
+ #
122
+ # @example Update access policy
123
+ # client.devices.update(mac: "AA:BB:CC:DD:EE:FF", access: "permit")
124
+ # # Sends: [{"ip":{"hotspot":{"host":{"mac":"aa:bb:cc:dd:ee:ff","permit":true}}}}]
125
+ #
126
+ # @example Assign routing policy (VPN)
127
+ # client.devices.update(mac: "AA:BB:CC:DD:EE:FF", policy: "Policy0")
128
+ # # Sends: [{"ip":{"hotspot":{"host":{"mac":"aa:bb:cc:dd:ee:ff","policy":"Policy0"}}}}]
129
+ #
130
+ # @example Remove routing policy (use default)
131
+ # client.devices.update(mac: "AA:BB:CC:DD:EE:FF", policy: "")
132
+ # # Sends: [{"ip":{"hotspot":{"host":{"mac":"aa:bb:cc:dd:ee:ff","policy":{"no":true}}}}}]
133
+ #
134
+ # @example Set static IP reservation
135
+ # client.devices.update(mac: "AA:BB:CC:DD:EE:FF", static_ip: "192.168.1.100")
136
+ # # Sends: [{"ip":{"dhcp":{"host":{"mac":"aa:bb:cc:dd:ee:ff","ip":"192.168.1.100"}}}}]
137
+ #
138
+ # @example Remove static IP reservation
139
+ # client.devices.update(mac: "AA:BB:CC:DD:EE:FF", static_ip: "")
140
+ # # Sends: [{"ip":{"dhcp":{"host":{"mac":"aa:bb:cc:dd:ee:ff","no":true}}}}]
141
+ #
142
+ # @example Update multiple properties (batched in single request)
143
+ # client.devices.update(mac: "AA:BB:CC:DD:EE:FF", name: "TV", access: "permit")
144
+ # # Sends: [{"known":{"host":{...}}}, {"ip":{"hotspot":{"host":{...}}}}]
145
+ #
146
+ def update(mac:, **attributes)
147
+ normalized_mac = mac.downcase
148
+ commands = []
149
+
150
+ # Name is set via 'known.host' RCI command
151
+ if attributes.key?(:name)
152
+ commands << { 'known' => { 'host' => { 'mac' => normalized_mac, 'name' => attributes[:name] } } }
153
+ end
154
+
155
+ # Access policy is set via 'ip.hotspot.host' RCI command
156
+ if attributes.key?(:access) || attributes.key?(:schedule)
157
+ hotspot_params = { 'mac' => normalized_mac }
158
+ hotspot_params['permit'] = true if attributes[:access] == 'permit'
159
+ hotspot_params['deny'] = true if attributes[:access] == 'deny'
160
+ hotspot_params['schedule'] = attributes[:schedule] if attributes.key?(:schedule)
161
+ commands << { 'ip' => { 'hotspot' => { 'host' => hotspot_params } } }
162
+ end
163
+
164
+ # Routing policy (VPN policy) is set via 'ip.hotspot.host.policy'
165
+ if attributes.key?(:policy)
166
+ policy_value = attributes[:policy]
167
+ if policy_value.nil? || policy_value.to_s.strip.empty?
168
+ # Remove policy assignment (use default routing)
169
+ commands << { 'ip' => { 'hotspot' => { 'host' => { 'mac' => normalized_mac, 'policy' => { 'no' => true } } } } }
170
+ else
171
+ # Assign specific policy
172
+ commands << { 'ip' => { 'hotspot' => { 'host' => { 'mac' => normalized_mac, 'policy' => policy_value } } } }
173
+ end
174
+ end
175
+
176
+ # Static IP reservation is set via 'ip.dhcp.host' RCI command
177
+ if attributes.key?(:static_ip)
178
+ static_ip_value = attributes[:static_ip]
179
+ if static_ip_value.nil? || static_ip_value.to_s.strip.empty?
180
+ # Remove static IP reservation
181
+ commands << { 'ip' => { 'dhcp' => { 'host' => { 'mac' => normalized_mac, 'no' => true } } } }
182
+ else
183
+ # Set static IP reservation
184
+ commands << { 'ip' => { 'dhcp' => { 'host' => { 'mac' => normalized_mac, 'ip' => static_ip_value } } } }
185
+ end
186
+ end
187
+
188
+ return {} if commands.empty?
189
+
190
+ client.batch(commands)
191
+ end
192
+
193
+ # Get active (currently connected) devices only.
194
+ #
195
+ # Filters the result of #all to return only devices with active: true.
196
+ #
197
+ # @return [Array<Hash>] List of active devices
198
+ # @example
199
+ # active = client.devices.active
200
+ # # => [{ mac: "AA:BB:CC:DD:EE:FF", name: "My Phone", active: true, ... }]
201
+ #
202
+ def active
203
+ all.select { |d| d[:active] }
204
+ end
205
+
206
+ # Delete device registration (remove from registered list).
207
+ #
208
+ # == Keenetic API Request
209
+ # POST /rci/ (batch format)
210
+ # Body: [{"ip":{"hotspot":{"host":{"mac":"aa:bb:cc:dd:ee:ff","no":true}}}}]
211
+ #
212
+ # The device will be unregistered but may reappear if it connects again.
213
+ #
214
+ # @param mac [String] Device MAC address (case-insensitive)
215
+ # @return [Array<Hash>] API response
216
+ # @example
217
+ # client.devices.delete(mac: 'AA:BB:CC:DD:EE:FF')
218
+ # # Sends: [{"ip":{"hotspot":{"host":{"mac":"aa:bb:cc:dd:ee:ff","no":true}}}}]
219
+ #
220
+ def delete(mac:)
221
+ client.batch([{ 'ip' => { 'hotspot' => { 'host' => { 'mac' => mac.downcase, 'no' => true } } } }])
222
+ end
223
+
224
+ private
225
+
226
+ def build_associations_lookup(associations_response)
227
+ stations = associations_response['station'] || []
228
+ stations = [stations] unless stations.is_a?(Array)
229
+
230
+ stations.each_with_object({}) do |station, lookup|
231
+ next unless station.is_a?(Hash) && station['mac']
232
+
233
+ mac = station['mac'].upcase
234
+ lookup[mac] = {
235
+ rssi: station['rssi'],
236
+ txrate: station['txrate'],
237
+ rxrate: station['rxrate'],
238
+ mode: station['mode'],
239
+ ht: station['ht'],
240
+ vht: station['vht'],
241
+ he: station['he'],
242
+ mcs: station['mcs'],
243
+ gi: station['gi']
244
+ }
245
+ end
246
+ end
247
+
248
+ def normalize_devices(response, associations_by_mac = {})
249
+ return [] if response.nil?
250
+
251
+ # Response can be an array directly or wrapped in 'host' key
252
+ hosts = response.is_a?(Array) ? response : (response['host'] || [response])
253
+ hosts = [hosts] unless hosts.is_a?(Array)
254
+
255
+ hosts.map { |host| normalize_device(host, associations_by_mac) }.compact
256
+ end
257
+
258
+ def normalize_device(host, associations_by_mac = {})
259
+ return nil unless host.is_a?(Hash)
260
+
261
+ mac = host['mac']
262
+ # Static IP is indicated by dhcp.static: true, and the IP is in the ip field
263
+ dhcp_info = host['dhcp']
264
+ is_static = dhcp_info.is_a?(Hash) && dhcp_info['static'] == true
265
+ static_ip = is_static ? host['ip'] : nil
266
+
267
+ # WiFi access point info - can be in mws.ap or ap field
268
+ mws_info = host['mws']
269
+ wifi_ap = mws_info.is_a?(Hash) ? mws_info['ap'] : host['ap']
270
+ # Mesh node ID (cid) identifies which mesh node the device is connected to
271
+ mws_cid = mws_info.is_a?(Hash) ? mws_info['cid'] : nil
272
+
273
+ # Get Wi-Fi association data if available
274
+ wifi_data = associations_by_mac[mac&.upcase] || {}
275
+
276
+ {
277
+ mac: mac,
278
+ name: host['name'] || host['hostname'],
279
+ hostname: host['hostname'],
280
+ ip: host['ip'],
281
+ static_ip: static_ip,
282
+ interface: host['interface'],
283
+ via: host['via'],
284
+ wifi_ap: wifi_ap,
285
+ mws_cid: mws_cid,
286
+ active: host['active'] == true || host['active'] == 'true',
287
+ registered: host['registered'] == true || host['registered'] == 'true',
288
+ access: host['access'],
289
+ schedule: host['schedule'],
290
+ rxbytes: host['rxbytes'],
291
+ txbytes: host['txbytes'],
292
+ uptime: host['uptime'],
293
+ first_seen: host['first-seen'],
294
+ last_seen: host['last-seen'],
295
+ link: host['link'],
296
+ # Wi-Fi association data (only present for active Wi-Fi clients)
297
+ rssi: wifi_data[:rssi],
298
+ txrate: wifi_data[:txrate],
299
+ rxrate: wifi_data[:rxrate],
300
+ wifi_mode: wifi_data[:mode],
301
+ wifi_ht: wifi_data[:ht],
302
+ wifi_vht: wifi_data[:vht],
303
+ wifi_he: wifi_data[:he]
304
+ }
305
+ end
306
+ end
307
+ end
308
+ end
309
+
@@ -0,0 +1,195 @@
1
+ module Keenetic
2
+ module Resources
3
+ # DHCP resource for managing DHCP leases and static bindings.
4
+ #
5
+ # == API Endpoints Used
6
+ #
7
+ # === Reading DHCP Leases
8
+ # GET /rci/show/ip/dhcp/lease
9
+ # Returns: Array of active DHCP leases
10
+ #
11
+ # === Reading Static Bindings
12
+ # GET /rci/show/ip/dhcp/binding
13
+ # Returns: Array of static IP reservations
14
+ #
15
+ # === Creating Static Binding
16
+ # POST /rci/ip/dhcp/host
17
+ # Body: { "mac": "AA:BB:CC:DD:EE:FF", "ip": "192.168.1.100", "name": "Server" }
18
+ #
19
+ # === Deleting Static Binding
20
+ # POST /rci/ip/dhcp/host
21
+ # Body: { "mac": "AA:BB:CC:DD:EE:FF", "no": true }
22
+ #
23
+ class DHCP < Base
24
+ # Get all active DHCP leases.
25
+ #
26
+ # == Keenetic API Request
27
+ # GET /rci/show/ip/dhcp/lease
28
+ #
29
+ # == Response Structure from API
30
+ # [
31
+ # {
32
+ # "ip": "192.168.1.100",
33
+ # "mac": "AA:BB:CC:DD:EE:FF",
34
+ # "hostname": "iphone",
35
+ # "expires": 1704067200
36
+ # }
37
+ # ]
38
+ #
39
+ # @return [Array<Hash>] List of normalized lease hashes
40
+ # @example
41
+ # leases = client.dhcp.leases
42
+ # # => [{ ip: "192.168.1.100", mac: "AA:BB:CC:DD:EE:FF", hostname: "iphone", expires: 1704067200 }]
43
+ #
44
+ def leases
45
+ response = get('/rci/show/ip/dhcp/lease')
46
+ normalize_leases(response)
47
+ end
48
+
49
+ # Get all static DHCP bindings (IP reservations).
50
+ #
51
+ # == Keenetic API Request
52
+ # GET /rci/show/ip/dhcp/binding
53
+ #
54
+ # == Response Structure from API
55
+ # [
56
+ # {
57
+ # "mac": "AA:BB:CC:DD:EE:FF",
58
+ # "ip": "192.168.1.100",
59
+ # "name": "My Server"
60
+ # }
61
+ # ]
62
+ #
63
+ # @return [Array<Hash>] List of normalized binding hashes
64
+ # @example
65
+ # bindings = client.dhcp.bindings
66
+ # # => [{ mac: "AA:BB:CC:DD:EE:FF", ip: "192.168.1.100", name: "My Server" }]
67
+ #
68
+ def bindings
69
+ response = get('/rci/show/ip/dhcp/binding')
70
+ normalize_bindings(response)
71
+ end
72
+
73
+ # Find a specific binding by MAC address.
74
+ #
75
+ # @param mac [String] MAC address (e.g., "AA:BB:CC:DD:EE:FF")
76
+ # @return [Hash, nil] Binding data or nil if not found
77
+ # @example
78
+ # binding = client.dhcp.find_binding(mac: "AA:BB:CC:DD:EE:FF")
79
+ # # => { mac: "AA:BB:CC:DD:EE:FF", ip: "192.168.1.100", name: "My Server" }
80
+ #
81
+ def find_binding(mac:)
82
+ normalized_mac = mac.upcase
83
+ bindings.find { |b| b[:mac]&.upcase == normalized_mac }
84
+ end
85
+
86
+ # Create a static DHCP binding (IP reservation).
87
+ #
88
+ # == Keenetic API Request
89
+ # POST /rci/ (batch format)
90
+ # Body: [{"ip": {"dhcp": {"host": {"mac": "...", "ip": "...", "name": "..."}}}}]
91
+ #
92
+ # @param mac [String] Device MAC address
93
+ # @param ip [String] IP address to reserve
94
+ # @param name [String, nil] Optional friendly name for the binding
95
+ # @return [Array<Hash>] API response
96
+ #
97
+ # @example Create binding with name
98
+ # client.dhcp.create_binding(mac: "AA:BB:CC:DD:EE:FF", ip: "192.168.1.100", name: "My Server")
99
+ #
100
+ # @example Create binding without name
101
+ # client.dhcp.create_binding(mac: "AA:BB:CC:DD:EE:FF", ip: "192.168.1.100")
102
+ #
103
+ def create_binding(mac:, ip:, name: nil)
104
+ params = {
105
+ 'mac' => mac.upcase,
106
+ 'ip' => ip
107
+ }
108
+ params['name'] = name if name
109
+
110
+ client.batch([{ 'ip' => { 'dhcp' => { 'host' => params } } }])
111
+ end
112
+
113
+ # Update an existing static DHCP binding.
114
+ #
115
+ # @param mac [String] Device MAC address
116
+ # @param ip [String, nil] New IP address (optional)
117
+ # @param name [String, nil] New name (optional)
118
+ # @return [Array<Hash>] API response
119
+ #
120
+ # @example Update binding IP
121
+ # client.dhcp.update_binding(mac: "AA:BB:CC:DD:EE:FF", ip: "192.168.1.101")
122
+ #
123
+ def update_binding(mac:, ip: nil, name: nil)
124
+ params = { 'mac' => mac.upcase }
125
+ params['ip'] = ip if ip
126
+ params['name'] = name if name
127
+
128
+ return {} if params.keys == ['mac']
129
+
130
+ client.batch([{ 'ip' => { 'dhcp' => { 'host' => params } } }])
131
+ end
132
+
133
+ # Delete a static DHCP binding.
134
+ #
135
+ # == Keenetic API Request
136
+ # POST /rci/ (batch format)
137
+ # Body: [{"ip": {"dhcp": {"host": {"mac": "...", "no": true}}}}]
138
+ #
139
+ # @param mac [String] Device MAC address
140
+ # @return [Array<Hash>] API response
141
+ #
142
+ # @example
143
+ # client.dhcp.delete_binding(mac: "AA:BB:CC:DD:EE:FF")
144
+ #
145
+ def delete_binding(mac:)
146
+ client.batch([{
147
+ 'ip' => {
148
+ 'dhcp' => {
149
+ 'host' => {
150
+ 'mac' => mac.upcase,
151
+ 'no' => true
152
+ }
153
+ }
154
+ }
155
+ }])
156
+ end
157
+
158
+ private
159
+
160
+ def normalize_leases(response)
161
+ return [] unless response.is_a?(Array)
162
+
163
+ response.map { |lease| normalize_lease(lease) }.compact
164
+ end
165
+
166
+ def normalize_lease(data)
167
+ return nil unless data.is_a?(Hash)
168
+
169
+ {
170
+ ip: data['ip'],
171
+ mac: data['mac'],
172
+ hostname: data['hostname'],
173
+ expires: data['expires']
174
+ }
175
+ end
176
+
177
+ def normalize_bindings(response)
178
+ return [] unless response.is_a?(Array)
179
+
180
+ response.map { |binding| normalize_binding(binding) }.compact
181
+ end
182
+
183
+ def normalize_binding(data)
184
+ return nil unless data.is_a?(Hash)
185
+
186
+ {
187
+ mac: data['mac'],
188
+ ip: data['ip'],
189
+ name: data['name']
190
+ }
191
+ end
192
+ end
193
+ end
194
+ end
195
+
@@ -0,0 +1,169 @@
1
+ module Keenetic
2
+ module Resources
3
+ # Internet resource for checking WAN connectivity status.
4
+ #
5
+ # == API Endpoints Used
6
+ #
7
+ # === Reading Internet Status
8
+ # GET /rci/show/internet/status
9
+ # Returns: { internet, gateway, dns, checked, checking, interface, address }
10
+ #
11
+ # === Reading Speed/Traffic (via Network interfaces)
12
+ # Uses client.network.interfaces internally to get WAN interface statistics
13
+ # Filters for interface with defaultgw flag
14
+ #
15
+ class Internet < Base
16
+ # Get internet connection status.
17
+ #
18
+ # == Keenetic API Request
19
+ # GET /rci/show/internet/status
20
+ #
21
+ # == Response Fields from API
22
+ # - internet: Boolean - true if internet is reachable
23
+ # - gateway: Default gateway IP address
24
+ # - dns: Array of DNS server addresses
25
+ # - checked: Timestamp of last connectivity check
26
+ # - checking: Boolean - check currently in progress
27
+ # - interface: Active WAN interface name
28
+ # - address: WAN IP address
29
+ #
30
+ # @return [Hash] Internet status with :connected, :gateway, :dns, :checked, :checking
31
+ # @example
32
+ # status = client.internet.status
33
+ # # => { connected: true, gateway: "10.0.0.1", dns: ["8.8.8.8", "8.8.4.4"], ... }
34
+ #
35
+ def status
36
+ response = get('/rci/show/internet/status')
37
+ normalize_status(response)
38
+ end
39
+
40
+ # Get current WAN traffic statistics.
41
+ #
42
+ # Uses the primary WAN interface (with defaultgw flag) from network.interfaces.
43
+ #
44
+ # == How It Works
45
+ # 1. Fetches all interfaces via client.network.interfaces
46
+ # 2. Finds interface with :defaultgw flag
47
+ # 3. Returns traffic counters from that interface
48
+ #
49
+ # @return [Hash, nil] WAN traffic stats or nil if no WAN interface found
50
+ # @example
51
+ # speed = client.internet.speed
52
+ # # => { interface: "ISP", rxbytes: 1073741824, txbytes: 536870912, uptime: 86400 }
53
+ #
54
+ def speed
55
+ iface = primary_wan_interface
56
+ return nil unless iface
57
+
58
+ {
59
+ interface: iface[:id],
60
+ rxbytes: iface[:rxbytes],
61
+ txbytes: iface[:txbytes],
62
+ rxpackets: iface[:rxpackets],
63
+ txpackets: iface[:txpackets],
64
+ uptime: iface[:uptime]
65
+ }
66
+ end
67
+
68
+ # Configure WAN connection settings.
69
+ #
70
+ # == Keenetic API Request
71
+ # POST /rci/ (batch format)
72
+ # Body: [{"interface": {"<wan_id>": {<config>}}}]
73
+ #
74
+ # == Configuration Types
75
+ #
76
+ # === PPPoE Connection
77
+ # configure('ISP', pppoe: { service: 'ISP', username: 'user', password: 'pass' })
78
+ #
79
+ # === Static IP Connection
80
+ # configure('ISP', address: '203.0.113.50', mask: '255.255.255.0', gateway: '203.0.113.1')
81
+ #
82
+ # === DHCP (IPoE) Connection
83
+ # configure('ISP', dhcp: true)
84
+ #
85
+ # @param interface_id [String] WAN interface ID (e.g., "ISP", "GigabitEthernet0")
86
+ # @param options [Hash] Configuration options
87
+ # @option options [Hash] :pppoe PPPoE settings { service:, username:, password: }
88
+ # @option options [String] :address Static IP address
89
+ # @option options [String] :mask Network mask
90
+ # @option options [String] :gateway Gateway address
91
+ # @option options [Boolean] :dhcp Enable DHCP
92
+ # @option options [Boolean] :up Enable (true) or disable (false) the interface
93
+ # @return [Array<Hash>] API response
94
+ #
95
+ # @example Configure PPPoE
96
+ # client.internet.configure('ISP',
97
+ # pppoe: { service: 'MyISP', username: 'user@isp.com', password: 'secret' },
98
+ # up: true
99
+ # )
100
+ # # Sends: [{"interface":{"ISP":{"pppoe":{"service":"MyISP",...},"up":true}}}]
101
+ #
102
+ # @example Configure Static IP
103
+ # client.internet.configure('ISP',
104
+ # address: '203.0.113.50',
105
+ # mask: '255.255.255.0',
106
+ # gateway: '203.0.113.1'
107
+ # )
108
+ #
109
+ # @example Configure DHCP
110
+ # client.internet.configure('ISP', dhcp: true, up: true)
111
+ # # Sends: [{"interface":{"ISP":{"ip":{"dhcp":true},"up":true}}}]
112
+ #
113
+ def configure(interface_id, **options)
114
+ params = {}
115
+
116
+ # Handle PPPoE configuration
117
+ if options[:pppoe]
118
+ params['pppoe'] = {
119
+ 'service' => options[:pppoe][:service],
120
+ 'username' => options[:pppoe][:username],
121
+ 'password' => options[:pppoe][:password]
122
+ }.compact
123
+ end
124
+
125
+ # Handle Static IP configuration
126
+ params['address'] = options[:address] if options[:address]
127
+ params['mask'] = options[:mask] if options[:mask]
128
+ params['gateway'] = options[:gateway] if options[:gateway]
129
+
130
+ # Handle DHCP configuration
131
+ if options[:dhcp]
132
+ params['ip'] = { 'dhcp' => true }
133
+ end
134
+
135
+ # Handle interface state
136
+ params['up'] = options[:up] unless options[:up].nil?
137
+
138
+ return {} if params.empty?
139
+
140
+ client.batch([{ 'interface' => { interface_id => params } }])
141
+ end
142
+
143
+ private
144
+
145
+ def normalize_status(response)
146
+ return {} unless response.is_a?(Hash)
147
+
148
+ {
149
+ connected: response['internet'] == true,
150
+ gateway: response['gateway'],
151
+ dns: normalize_dns(response['dns']),
152
+ checked: response['checked'],
153
+ checking: response['checking']
154
+ }
155
+ end
156
+
157
+ def normalize_dns(dns)
158
+ return [] unless dns.is_a?(Array)
159
+ dns
160
+ end
161
+
162
+ def primary_wan_interface
163
+ interfaces = client.network.interfaces
164
+ interfaces.find { |i| i[:defaultgw] }
165
+ end
166
+ end
167
+ end
168
+ end
169
+