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.
- checksums.yaml +7 -0
- data/README.md +83 -0
- data/lib/keenetic/client.rb +361 -0
- data/lib/keenetic/configuration.rb +27 -0
- data/lib/keenetic/errors.rb +19 -0
- data/lib/keenetic/resources/base.rb +76 -0
- data/lib/keenetic/resources/devices.rb +309 -0
- data/lib/keenetic/resources/dhcp.rb +195 -0
- data/lib/keenetic/resources/internet.rb +169 -0
- data/lib/keenetic/resources/logs.rb +330 -0
- data/lib/keenetic/resources/network.rb +251 -0
- data/lib/keenetic/resources/policies.rb +171 -0
- data/lib/keenetic/resources/ports.rb +115 -0
- data/lib/keenetic/resources/routing.rb +200 -0
- data/lib/keenetic/resources/system.rb +267 -0
- data/lib/keenetic/resources/wifi.rb +376 -0
- data/lib/keenetic/version.rb +4 -0
- data/lib/keenetic.rb +99 -0
- metadata +89 -0
|
@@ -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
|
+
|