keenetic 0.1.0 → 1.0.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 +4 -4
- data/LICENSE.txt +21 -0
- data/README.md +28 -26
- data/lib/keenetic/client.rb +131 -1
- data/lib/keenetic/resources/base.rb +4 -0
- data/lib/keenetic/resources/components.rb +88 -0
- data/lib/keenetic/resources/config.rb +89 -0
- data/lib/keenetic/resources/diagnostics.rb +102 -0
- data/lib/keenetic/resources/dns.rb +95 -0
- data/lib/keenetic/resources/dyndns.rb +71 -0
- data/lib/keenetic/resources/firewall.rb +103 -0
- data/lib/keenetic/resources/hotspot.rb +282 -0
- data/lib/keenetic/resources/ipv6.rb +74 -0
- data/lib/keenetic/resources/mesh.rb +84 -0
- data/lib/keenetic/resources/nat.rb +202 -0
- data/lib/keenetic/resources/qos.rb +89 -0
- data/lib/keenetic/resources/routes.rb +432 -0
- data/lib/keenetic/resources/schedule.rb +87 -0
- data/lib/keenetic/resources/system.rb +134 -0
- data/lib/keenetic/resources/usb.rb +135 -0
- data/lib/keenetic/resources/users.rb +92 -0
- data/lib/keenetic/resources/vpn.rb +153 -0
- data/lib/keenetic/version.rb +1 -2
- data/lib/keenetic.rb +33 -0
- metadata +20 -3
|
@@ -0,0 +1,432 @@
|
|
|
1
|
+
module Keenetic
|
|
2
|
+
module Resources
|
|
3
|
+
# Manages static routes configuration on the router.
|
|
4
|
+
#
|
|
5
|
+
# == API Endpoints Used
|
|
6
|
+
#
|
|
7
|
+
# === Reading Static Routes
|
|
8
|
+
# POST /rci/ (batch format)
|
|
9
|
+
# Body: [{"show": {"sc": {"ip": {"route": {}}}}}]
|
|
10
|
+
# Returns: Static routes from router configuration
|
|
11
|
+
#
|
|
12
|
+
# === Adding Static Routes
|
|
13
|
+
# POST /rci/ (batch format)
|
|
14
|
+
# Body: [
|
|
15
|
+
# {"webhelp": {"event": {"push": {"data": "..."}}}},
|
|
16
|
+
# {"ip": {"route": {...}}},
|
|
17
|
+
# {"system": {"configuration": {"save": {}}}}
|
|
18
|
+
# ]
|
|
19
|
+
#
|
|
20
|
+
# === Deleting Static Routes
|
|
21
|
+
# POST /rci/ (batch format)
|
|
22
|
+
# Body: [
|
|
23
|
+
# {"webhelp": {"event": {"push": {"data": "..."}}}},
|
|
24
|
+
# {"ip": {"route": {"no": true, ...}}},
|
|
25
|
+
# {"system": {"configuration": {"save": {}}}}
|
|
26
|
+
# ]
|
|
27
|
+
#
|
|
28
|
+
# == CIDR Support
|
|
29
|
+
# The gem automatically converts CIDR notation to subnet masks:
|
|
30
|
+
# host: "10.0.0.0/24" -> network: "10.0.0.0", mask: "255.255.255.0"
|
|
31
|
+
# host: "1.2.3.4/32" -> host: "1.2.3.4"
|
|
32
|
+
#
|
|
33
|
+
class Routes < Base
|
|
34
|
+
# CIDR prefix to subnet mask mapping
|
|
35
|
+
CIDR_TO_MASK = {
|
|
36
|
+
8 => '255.0.0.0',
|
|
37
|
+
9 => '255.128.0.0',
|
|
38
|
+
10 => '255.192.0.0',
|
|
39
|
+
11 => '255.224.0.0',
|
|
40
|
+
12 => '255.240.0.0',
|
|
41
|
+
13 => '255.248.0.0',
|
|
42
|
+
14 => '255.252.0.0',
|
|
43
|
+
15 => '255.254.0.0',
|
|
44
|
+
16 => '255.255.0.0',
|
|
45
|
+
17 => '255.255.128.0',
|
|
46
|
+
18 => '255.255.192.0',
|
|
47
|
+
19 => '255.255.224.0',
|
|
48
|
+
20 => '255.255.240.0',
|
|
49
|
+
21 => '255.255.248.0',
|
|
50
|
+
22 => '255.255.252.0',
|
|
51
|
+
23 => '255.255.254.0',
|
|
52
|
+
24 => '255.255.255.0',
|
|
53
|
+
25 => '255.255.255.128',
|
|
54
|
+
26 => '255.255.255.192',
|
|
55
|
+
27 => '255.255.255.224',
|
|
56
|
+
28 => '255.255.255.240',
|
|
57
|
+
29 => '255.255.255.248',
|
|
58
|
+
30 => '255.255.255.252',
|
|
59
|
+
31 => '255.255.255.254',
|
|
60
|
+
32 => '255.255.255.255'
|
|
61
|
+
}.freeze
|
|
62
|
+
|
|
63
|
+
# Get all static routes from router configuration.
|
|
64
|
+
#
|
|
65
|
+
# == Keenetic API Request
|
|
66
|
+
# POST /rci/ (batch format)
|
|
67
|
+
# Body: [{"show": {"sc": {"ip": {"route": {}}}}}]
|
|
68
|
+
#
|
|
69
|
+
# == Response Structure from API
|
|
70
|
+
# [
|
|
71
|
+
# {
|
|
72
|
+
# "network": "10.0.0.0",
|
|
73
|
+
# "mask": "255.255.255.0",
|
|
74
|
+
# "interface": "Wireguard0",
|
|
75
|
+
# "gateway": "",
|
|
76
|
+
# "auto": true,
|
|
77
|
+
# "reject": false,
|
|
78
|
+
# "comment": "VPN route"
|
|
79
|
+
# }
|
|
80
|
+
# ]
|
|
81
|
+
#
|
|
82
|
+
# @return [Array<Hash>] List of normalized static route hashes
|
|
83
|
+
# @example
|
|
84
|
+
# routes = client.routes.all
|
|
85
|
+
# # => [{ network: "10.0.0.0", mask: "255.255.255.0", interface: "Wireguard0", ... }]
|
|
86
|
+
#
|
|
87
|
+
def all
|
|
88
|
+
response = client.batch([{ 'show' => { 'sc' => { 'ip' => { 'route' => {} } } } }])
|
|
89
|
+
routes_data = extract_routes_from_response(response)
|
|
90
|
+
normalize_routes(routes_data)
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
# Add a single static route.
|
|
94
|
+
#
|
|
95
|
+
# == Keenetic API Request
|
|
96
|
+
# POST /rci/ (batch format)
|
|
97
|
+
# Body: [
|
|
98
|
+
# {"webhelp": {"event": {"push": {"data": "{\"type\":\"configuration_change\",\"value\":{\"url\":\"/staticRoutes\"}}"}}}},
|
|
99
|
+
# {"ip": {"route": {"host|network": "...", "mask": "...", "interface": "...", ...}}},
|
|
100
|
+
# {"system": {"configuration": {"save": {}}}}
|
|
101
|
+
# ]
|
|
102
|
+
#
|
|
103
|
+
# @param host [String, nil] Single host IP (e.g., "1.2.3.4" or "1.2.3.4/32"), mutually exclusive with network/mask
|
|
104
|
+
# @param network [String, nil] Network address (e.g., "10.0.0.0" or "10.0.0.0/24")
|
|
105
|
+
# @param mask [String, nil] Subnet mask (e.g., "255.255.255.0"), required with network unless CIDR notation used
|
|
106
|
+
# @param interface [String] Interface name (required, e.g., "Wireguard0")
|
|
107
|
+
# @param comment [String] Route description (required)
|
|
108
|
+
# @param gateway [String] Gateway address (optional, default "")
|
|
109
|
+
# @param auto [Boolean] Auto-enable route (optional, default true)
|
|
110
|
+
# @param reject [Boolean] Reject route (optional, default false)
|
|
111
|
+
# @return [Array<Hash>] API response
|
|
112
|
+
# @raise [ArgumentError] if required parameters are missing or invalid
|
|
113
|
+
#
|
|
114
|
+
# @example Add host route
|
|
115
|
+
# client.routes.add(host: "1.2.3.4", interface: "Wireguard0", comment: "VPN host")
|
|
116
|
+
#
|
|
117
|
+
# @example Add network route with CIDR
|
|
118
|
+
# client.routes.add(network: "10.0.0.0/24", interface: "Wireguard0", comment: "VPN network")
|
|
119
|
+
#
|
|
120
|
+
# @example Add network route with explicit mask
|
|
121
|
+
# client.routes.add(network: "10.0.0.0", mask: "255.255.255.0", interface: "Wireguard0", comment: "VPN network")
|
|
122
|
+
#
|
|
123
|
+
def add(host: nil, network: nil, mask: nil, interface:, comment:, gateway: '', auto: true, reject: false)
|
|
124
|
+
route_params = build_route_params(
|
|
125
|
+
host: host,
|
|
126
|
+
network: network,
|
|
127
|
+
mask: mask,
|
|
128
|
+
interface: interface,
|
|
129
|
+
comment: comment,
|
|
130
|
+
gateway: gateway,
|
|
131
|
+
auto: auto,
|
|
132
|
+
reject: reject
|
|
133
|
+
)
|
|
134
|
+
|
|
135
|
+
commands = [
|
|
136
|
+
webhelp_event,
|
|
137
|
+
{ 'ip' => { 'route' => route_params } },
|
|
138
|
+
save_config_command
|
|
139
|
+
]
|
|
140
|
+
|
|
141
|
+
response = client.batch(commands)
|
|
142
|
+
validate_route_response(response)
|
|
143
|
+
response
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
# Add multiple static routes in a single request.
|
|
147
|
+
#
|
|
148
|
+
# == Keenetic API Request
|
|
149
|
+
# POST /rci/ (batch format)
|
|
150
|
+
# Body: [
|
|
151
|
+
# {"webhelp": {"event": {"push": {"data": "..."}}}},
|
|
152
|
+
# {"ip": {"route": {...}}},
|
|
153
|
+
# {"ip": {"route": {...}}},
|
|
154
|
+
# ...
|
|
155
|
+
# {"system": {"configuration": {"save": {}}}}
|
|
156
|
+
# ]
|
|
157
|
+
#
|
|
158
|
+
# @param routes [Array<Hash>] Array of route parameter hashes (same format as #add)
|
|
159
|
+
# @return [Array<Hash>] API response
|
|
160
|
+
# @raise [ArgumentError] if routes is empty or any route has invalid parameters
|
|
161
|
+
#
|
|
162
|
+
# @example Add multiple routes
|
|
163
|
+
# client.routes.add_batch([
|
|
164
|
+
# { host: "1.2.3.4", interface: "Wireguard0", comment: "Host 1" },
|
|
165
|
+
# { network: "10.0.0.0/24", interface: "Wireguard0", comment: "Network 1" }
|
|
166
|
+
# ])
|
|
167
|
+
#
|
|
168
|
+
def add_batch(routes)
|
|
169
|
+
raise ArgumentError, 'Routes array cannot be empty' if routes.nil? || routes.empty?
|
|
170
|
+
|
|
171
|
+
commands = [webhelp_event]
|
|
172
|
+
|
|
173
|
+
routes.each do |route|
|
|
174
|
+
route_params = build_route_params(
|
|
175
|
+
host: route[:host],
|
|
176
|
+
network: route[:network],
|
|
177
|
+
mask: route[:mask],
|
|
178
|
+
interface: route[:interface],
|
|
179
|
+
comment: route[:comment],
|
|
180
|
+
gateway: route[:gateway] || '',
|
|
181
|
+
auto: route.fetch(:auto, true),
|
|
182
|
+
reject: route.fetch(:reject, false)
|
|
183
|
+
)
|
|
184
|
+
commands << { 'ip' => { 'route' => route_params } }
|
|
185
|
+
end
|
|
186
|
+
|
|
187
|
+
commands << save_config_command
|
|
188
|
+
|
|
189
|
+
response = client.batch(commands)
|
|
190
|
+
validate_batch_response(response, routes.size)
|
|
191
|
+
response
|
|
192
|
+
end
|
|
193
|
+
|
|
194
|
+
# Delete a single static route.
|
|
195
|
+
#
|
|
196
|
+
# == Keenetic API Request
|
|
197
|
+
# POST /rci/ (batch format)
|
|
198
|
+
# Body: [
|
|
199
|
+
# {"webhelp": {"event": {"push": {"data": "..."}}}},
|
|
200
|
+
# {"ip": {"route": {"no": true, "host|network": "...", "mask": "..."}}},
|
|
201
|
+
# {"system": {"configuration": {"save": {}}}}
|
|
202
|
+
# ]
|
|
203
|
+
#
|
|
204
|
+
# @param host [String, nil] Single host IP (e.g., "1.2.3.4")
|
|
205
|
+
# @param network [String, nil] Network address (e.g., "10.0.0.0")
|
|
206
|
+
# @param mask [String, nil] Subnet mask (e.g., "255.255.255.0")
|
|
207
|
+
# @return [Array<Hash>] API response
|
|
208
|
+
# @raise [ArgumentError] if neither host nor network is provided
|
|
209
|
+
#
|
|
210
|
+
# @example Delete host route
|
|
211
|
+
# client.routes.delete(host: "1.2.3.4")
|
|
212
|
+
#
|
|
213
|
+
# @example Delete network route with CIDR
|
|
214
|
+
# client.routes.delete(network: "10.0.0.0/24")
|
|
215
|
+
#
|
|
216
|
+
# @example Delete network route with explicit mask
|
|
217
|
+
# client.routes.delete(network: "10.0.0.0", mask: "255.255.255.0")
|
|
218
|
+
#
|
|
219
|
+
def delete(host: nil, network: nil, mask: nil)
|
|
220
|
+
route_params = build_delete_params(host: host, network: network, mask: mask)
|
|
221
|
+
|
|
222
|
+
commands = [
|
|
223
|
+
webhelp_event,
|
|
224
|
+
{ 'ip' => { 'route' => route_params } },
|
|
225
|
+
save_config_command
|
|
226
|
+
]
|
|
227
|
+
|
|
228
|
+
client.batch(commands)
|
|
229
|
+
end
|
|
230
|
+
|
|
231
|
+
# Delete multiple static routes in a single request.
|
|
232
|
+
#
|
|
233
|
+
# == Keenetic API Request
|
|
234
|
+
# POST /rci/ (batch format)
|
|
235
|
+
# Body: [
|
|
236
|
+
# {"webhelp": {"event": {"push": {"data": "..."}}}},
|
|
237
|
+
# {"ip": {"route": {"no": true, ...}}},
|
|
238
|
+
# {"ip": {"route": {"no": true, ...}}},
|
|
239
|
+
# ...
|
|
240
|
+
# {"system": {"configuration": {"save": {}}}}
|
|
241
|
+
# ]
|
|
242
|
+
#
|
|
243
|
+
# @param routes [Array<Hash>] Array of route identifiers (host or network/mask)
|
|
244
|
+
# @return [Array<Hash>] API response
|
|
245
|
+
# @raise [ArgumentError] if routes is empty
|
|
246
|
+
#
|
|
247
|
+
# @example Delete multiple routes
|
|
248
|
+
# client.routes.delete_batch([
|
|
249
|
+
# { host: "1.2.3.4" },
|
|
250
|
+
# { network: "10.0.0.0/24" }
|
|
251
|
+
# ])
|
|
252
|
+
#
|
|
253
|
+
def delete_batch(routes)
|
|
254
|
+
raise ArgumentError, 'Routes array cannot be empty' if routes.nil? || routes.empty?
|
|
255
|
+
|
|
256
|
+
commands = [webhelp_event]
|
|
257
|
+
|
|
258
|
+
routes.each do |route|
|
|
259
|
+
route_params = build_delete_params(
|
|
260
|
+
host: route[:host],
|
|
261
|
+
network: route[:network],
|
|
262
|
+
mask: route[:mask]
|
|
263
|
+
)
|
|
264
|
+
commands << { 'ip' => { 'route' => route_params } }
|
|
265
|
+
end
|
|
266
|
+
|
|
267
|
+
commands << save_config_command
|
|
268
|
+
|
|
269
|
+
client.batch(commands)
|
|
270
|
+
end
|
|
271
|
+
|
|
272
|
+
# Convert CIDR notation to subnet mask.
|
|
273
|
+
#
|
|
274
|
+
# @param cidr [Integer, String] CIDR prefix length (8-32)
|
|
275
|
+
# @return [String] Subnet mask in dotted notation
|
|
276
|
+
# @raise [ArgumentError] if CIDR is invalid
|
|
277
|
+
#
|
|
278
|
+
# @example
|
|
279
|
+
# Keenetic::Resources::Routes.cidr_to_mask(24)
|
|
280
|
+
# # => "255.255.255.0"
|
|
281
|
+
#
|
|
282
|
+
def self.cidr_to_mask(cidr)
|
|
283
|
+
prefix = cidr.to_i
|
|
284
|
+
raise ArgumentError, "Invalid CIDR prefix: #{cidr}" unless CIDR_TO_MASK.key?(prefix)
|
|
285
|
+
|
|
286
|
+
CIDR_TO_MASK[prefix]
|
|
287
|
+
end
|
|
288
|
+
|
|
289
|
+
private
|
|
290
|
+
|
|
291
|
+
def extract_routes_from_response(response)
|
|
292
|
+
return [] unless response.is_a?(Array) && response.first.is_a?(Hash)
|
|
293
|
+
|
|
294
|
+
response.dig(0, 'show', 'sc', 'ip', 'route') || []
|
|
295
|
+
end
|
|
296
|
+
|
|
297
|
+
def normalize_routes(routes_data)
|
|
298
|
+
return [] unless routes_data.is_a?(Array)
|
|
299
|
+
|
|
300
|
+
routes_data.map { |route| normalize_route(route) }.compact
|
|
301
|
+
end
|
|
302
|
+
|
|
303
|
+
def normalize_route(data)
|
|
304
|
+
return nil unless data.is_a?(Hash)
|
|
305
|
+
|
|
306
|
+
{
|
|
307
|
+
network: data['network'],
|
|
308
|
+
mask: data['mask'],
|
|
309
|
+
host: data['host'],
|
|
310
|
+
interface: data['interface'],
|
|
311
|
+
gateway: data['gateway'],
|
|
312
|
+
comment: data['comment'],
|
|
313
|
+
auto: normalize_boolean(data['auto']),
|
|
314
|
+
reject: normalize_boolean(data['reject'])
|
|
315
|
+
}
|
|
316
|
+
end
|
|
317
|
+
|
|
318
|
+
def build_route_params(host:, network:, mask:, interface:, comment:, gateway:, auto:, reject:)
|
|
319
|
+
raise ArgumentError, 'Interface is required' if interface.nil? || interface.to_s.strip.empty?
|
|
320
|
+
raise ArgumentError, 'Comment is required' if comment.nil? || comment.to_s.strip.empty?
|
|
321
|
+
|
|
322
|
+
params = {}
|
|
323
|
+
|
|
324
|
+
if host && !host.to_s.strip.empty?
|
|
325
|
+
parsed = parse_cidr(host)
|
|
326
|
+
if parsed[:cidr] == 32 || parsed[:cidr].nil?
|
|
327
|
+
params['host'] = parsed[:address]
|
|
328
|
+
else
|
|
329
|
+
# If CIDR is not /32, treat as network
|
|
330
|
+
params['network'] = parsed[:address]
|
|
331
|
+
params['mask'] = self.class.cidr_to_mask(parsed[:cidr])
|
|
332
|
+
end
|
|
333
|
+
elsif network && !network.to_s.strip.empty?
|
|
334
|
+
parsed = parse_cidr(network)
|
|
335
|
+
params['network'] = parsed[:address]
|
|
336
|
+
params['mask'] = parsed[:cidr] ? self.class.cidr_to_mask(parsed[:cidr]) : mask
|
|
337
|
+
raise ArgumentError, 'Mask is required for network routes without CIDR notation' if params['mask'].nil?
|
|
338
|
+
else
|
|
339
|
+
raise ArgumentError, 'Either host or network must be provided'
|
|
340
|
+
end
|
|
341
|
+
|
|
342
|
+
params['interface'] = interface
|
|
343
|
+
params['comment'] = comment
|
|
344
|
+
params['gateway'] = gateway.to_s
|
|
345
|
+
params['auto'] = auto
|
|
346
|
+
params['reject'] = reject
|
|
347
|
+
|
|
348
|
+
params
|
|
349
|
+
end
|
|
350
|
+
|
|
351
|
+
def build_delete_params(host:, network:, mask:)
|
|
352
|
+
params = { 'no' => true }
|
|
353
|
+
|
|
354
|
+
if host && !host.to_s.strip.empty?
|
|
355
|
+
parsed = parse_cidr(host)
|
|
356
|
+
if parsed[:cidr] == 32 || parsed[:cidr].nil?
|
|
357
|
+
params['host'] = parsed[:address]
|
|
358
|
+
else
|
|
359
|
+
params['network'] = parsed[:address]
|
|
360
|
+
params['mask'] = self.class.cidr_to_mask(parsed[:cidr])
|
|
361
|
+
end
|
|
362
|
+
elsif network && !network.to_s.strip.empty?
|
|
363
|
+
parsed = parse_cidr(network)
|
|
364
|
+
params['network'] = parsed[:address]
|
|
365
|
+
params['mask'] = parsed[:cidr] ? self.class.cidr_to_mask(parsed[:cidr]) : mask
|
|
366
|
+
raise ArgumentError, 'Mask is required for network routes without CIDR notation' if params['mask'].nil?
|
|
367
|
+
else
|
|
368
|
+
raise ArgumentError, 'Either host or network must be provided'
|
|
369
|
+
end
|
|
370
|
+
|
|
371
|
+
params
|
|
372
|
+
end
|
|
373
|
+
|
|
374
|
+
def parse_cidr(address)
|
|
375
|
+
return { address: address, cidr: nil } unless address.to_s.include?('/')
|
|
376
|
+
|
|
377
|
+
parts = address.to_s.split('/')
|
|
378
|
+
{ address: parts[0], cidr: parts[1].to_i }
|
|
379
|
+
end
|
|
380
|
+
|
|
381
|
+
def webhelp_event
|
|
382
|
+
{
|
|
383
|
+
'webhelp' => {
|
|
384
|
+
'event' => {
|
|
385
|
+
'push' => {
|
|
386
|
+
'data' => '{"type":"configuration_change","value":{"url":"/staticRoutes"}}'
|
|
387
|
+
}
|
|
388
|
+
}
|
|
389
|
+
}
|
|
390
|
+
}
|
|
391
|
+
end
|
|
392
|
+
|
|
393
|
+
def save_config_command
|
|
394
|
+
{ 'system' => { 'configuration' => { 'save' => {} } } }
|
|
395
|
+
end
|
|
396
|
+
|
|
397
|
+
def validate_route_response(response)
|
|
398
|
+
return unless response.is_a?(Array)
|
|
399
|
+
|
|
400
|
+
# Response index 1 is the route command response
|
|
401
|
+
route_response = response[1]
|
|
402
|
+
return unless route_response.is_a?(Hash)
|
|
403
|
+
|
|
404
|
+
status = route_response.dig('ip', 'route', 'status')
|
|
405
|
+
return unless status.is_a?(Array) && status.first.is_a?(Hash)
|
|
406
|
+
|
|
407
|
+
if status.first['status'] == 'error'
|
|
408
|
+
error_msg = status.first['message'] || 'Unknown error'
|
|
409
|
+
raise ApiError.new("Failed to add route: #{error_msg}")
|
|
410
|
+
end
|
|
411
|
+
end
|
|
412
|
+
|
|
413
|
+
def validate_batch_response(response, routes_count)
|
|
414
|
+
return unless response.is_a?(Array)
|
|
415
|
+
|
|
416
|
+
# Route responses start at index 1 (after webhelp event)
|
|
417
|
+
(1..routes_count).each do |i|
|
|
418
|
+
route_response = response[i]
|
|
419
|
+
next unless route_response.is_a?(Hash)
|
|
420
|
+
|
|
421
|
+
status = route_response.dig('ip', 'route', 'status')
|
|
422
|
+
next unless status.is_a?(Array) && status.first.is_a?(Hash)
|
|
423
|
+
|
|
424
|
+
if status.first['status'] == 'error'
|
|
425
|
+
error_msg = status.first['message'] || 'Unknown error'
|
|
426
|
+
raise ApiError.new("Failed to add route at index #{i - 1}: #{error_msg}")
|
|
427
|
+
end
|
|
428
|
+
end
|
|
429
|
+
end
|
|
430
|
+
end
|
|
431
|
+
end
|
|
432
|
+
end
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
module Keenetic
|
|
2
|
+
module Resources
|
|
3
|
+
# Schedule resource for managing access control schedules.
|
|
4
|
+
#
|
|
5
|
+
# == API Endpoints Used
|
|
6
|
+
#
|
|
7
|
+
# === List Schedules
|
|
8
|
+
# GET /rci/show/schedule
|
|
9
|
+
#
|
|
10
|
+
# === Create Schedule
|
|
11
|
+
# POST /rci/schedule
|
|
12
|
+
#
|
|
13
|
+
# === Delete Schedule
|
|
14
|
+
# POST /rci/schedule with { "name": "...", "no": true }
|
|
15
|
+
#
|
|
16
|
+
class Schedule < Base
|
|
17
|
+
# List all schedules.
|
|
18
|
+
#
|
|
19
|
+
# @return [Array<Hash>] List of schedules
|
|
20
|
+
# @example
|
|
21
|
+
# schedules = client.schedule.all
|
|
22
|
+
#
|
|
23
|
+
def all
|
|
24
|
+
response = get('/rci/show/schedule')
|
|
25
|
+
normalize_schedules(response)
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
# Find a schedule by name.
|
|
29
|
+
#
|
|
30
|
+
# @param name [String] Schedule name
|
|
31
|
+
# @return [Hash, nil] Schedule data or nil
|
|
32
|
+
#
|
|
33
|
+
def find(name)
|
|
34
|
+
all.find { |s| s[:name] == name }
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
# Create a new schedule.
|
|
38
|
+
#
|
|
39
|
+
# @param name [String] Schedule name
|
|
40
|
+
# @param entries [Array<Hash>] Schedule entries with days, start, end, action
|
|
41
|
+
# @return [Hash, nil] API response
|
|
42
|
+
# @example
|
|
43
|
+
# client.schedule.create(
|
|
44
|
+
# name: 'kids_bedtime',
|
|
45
|
+
# entries: [
|
|
46
|
+
# { days: 'mon,tue,wed,thu,fri', start: '22:00', end: '07:00', action: 'deny' }
|
|
47
|
+
# ]
|
|
48
|
+
# )
|
|
49
|
+
#
|
|
50
|
+
def create(name:, entries:)
|
|
51
|
+
post('/rci/schedule', { 'name' => name, 'entries' => entries })
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
# Delete a schedule.
|
|
55
|
+
#
|
|
56
|
+
# @param name [String] Schedule name
|
|
57
|
+
# @return [Hash, nil] API response
|
|
58
|
+
#
|
|
59
|
+
def delete(name:)
|
|
60
|
+
post('/rci/schedule', { 'name' => name, 'no' => true })
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
private
|
|
64
|
+
|
|
65
|
+
def normalize_schedules(response)
|
|
66
|
+
schedules_data = case response
|
|
67
|
+
when Array
|
|
68
|
+
response
|
|
69
|
+
when Hash
|
|
70
|
+
response['schedule'] || response['schedules'] || []
|
|
71
|
+
else
|
|
72
|
+
[]
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
return [] unless schedules_data.is_a?(Array)
|
|
76
|
+
|
|
77
|
+
schedules_data.map { |schedule| normalize_schedule(schedule) }.compact
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
def normalize_schedule(data)
|
|
81
|
+
return nil unless data.is_a?(Hash)
|
|
82
|
+
|
|
83
|
+
deep_normalize_keys(data)
|
|
84
|
+
end
|
|
85
|
+
end
|
|
86
|
+
end
|
|
87
|
+
end
|
|
@@ -138,8 +138,142 @@ module Keenetic
|
|
|
138
138
|
normalize_license(response)
|
|
139
139
|
end
|
|
140
140
|
|
|
141
|
+
# Reboot the router.
|
|
142
|
+
#
|
|
143
|
+
# == Keenetic API Request
|
|
144
|
+
# POST /rci/system/reboot
|
|
145
|
+
# Body: {}
|
|
146
|
+
#
|
|
147
|
+
# == Warning
|
|
148
|
+
# This will immediately restart the router. All active connections
|
|
149
|
+
# will be dropped and the router will be unavailable for 1-2 minutes.
|
|
150
|
+
#
|
|
151
|
+
# @return [Hash, nil] API response
|
|
152
|
+
# @example
|
|
153
|
+
# client.system.reboot
|
|
154
|
+
#
|
|
155
|
+
def reboot
|
|
156
|
+
post('/rci/system/reboot', {})
|
|
157
|
+
end
|
|
158
|
+
|
|
159
|
+
# Factory reset the router.
|
|
160
|
+
#
|
|
161
|
+
# == Keenetic API Request
|
|
162
|
+
# POST /rci/system/default
|
|
163
|
+
# Body: {}
|
|
164
|
+
#
|
|
165
|
+
# == Warning
|
|
166
|
+
# This will erase ALL configuration and restore factory defaults.
|
|
167
|
+
# The router will reboot and you will lose access until you
|
|
168
|
+
# reconfigure it. Use with extreme caution!
|
|
169
|
+
#
|
|
170
|
+
# @return [Hash, nil] API response
|
|
171
|
+
# @example
|
|
172
|
+
# client.system.factory_reset
|
|
173
|
+
#
|
|
174
|
+
def factory_reset
|
|
175
|
+
post('/rci/system/default', {})
|
|
176
|
+
end
|
|
177
|
+
|
|
178
|
+
# Check for available firmware updates.
|
|
179
|
+
#
|
|
180
|
+
# == Keenetic API Request
|
|
181
|
+
# GET /rci/show/system/update
|
|
182
|
+
#
|
|
183
|
+
# == Response Fields
|
|
184
|
+
# - available: Whether an update is available
|
|
185
|
+
# - version: Available firmware version
|
|
186
|
+
# - current: Current firmware version
|
|
187
|
+
# - channel: Update channel (stable, preview)
|
|
188
|
+
#
|
|
189
|
+
# @return [Hash] Update availability information
|
|
190
|
+
# @example
|
|
191
|
+
# update_info = client.system.check_updates
|
|
192
|
+
# # => { available: true, version: "4.2.0", current: "4.1.0", ... }
|
|
193
|
+
#
|
|
194
|
+
def check_updates
|
|
195
|
+
response = get('/rci/show/system/update')
|
|
196
|
+
normalize_update_info(response)
|
|
197
|
+
end
|
|
198
|
+
|
|
199
|
+
# Apply available firmware update.
|
|
200
|
+
#
|
|
201
|
+
# == Keenetic API Request
|
|
202
|
+
# POST /rci/system/update
|
|
203
|
+
# Body: {}
|
|
204
|
+
#
|
|
205
|
+
# == Warning
|
|
206
|
+
# This will download and install the latest firmware update.
|
|
207
|
+
# The router will reboot automatically after the update is applied.
|
|
208
|
+
# Do not power off the router during the update process!
|
|
209
|
+
#
|
|
210
|
+
# @return [Hash, nil] API response
|
|
211
|
+
# @example
|
|
212
|
+
# client.system.apply_update
|
|
213
|
+
#
|
|
214
|
+
def apply_update
|
|
215
|
+
post('/rci/system/update', {})
|
|
216
|
+
end
|
|
217
|
+
|
|
218
|
+
# Control LED mode on the router.
|
|
219
|
+
#
|
|
220
|
+
# == Keenetic API Request
|
|
221
|
+
# POST /rci/system/led
|
|
222
|
+
# Body: { "mode": "on" | "off" | "auto" }
|
|
223
|
+
#
|
|
224
|
+
# @param mode [String] LED mode: "on", "off", or "auto"
|
|
225
|
+
# @return [Hash, nil] API response
|
|
226
|
+
# @raise [ArgumentError] if mode is invalid
|
|
227
|
+
#
|
|
228
|
+
# @example Turn off LEDs
|
|
229
|
+
# client.system.set_led_mode('off')
|
|
230
|
+
#
|
|
231
|
+
# @example Set to automatic
|
|
232
|
+
# client.system.set_led_mode('auto')
|
|
233
|
+
#
|
|
234
|
+
def set_led_mode(mode)
|
|
235
|
+
valid_modes = %w[on off auto]
|
|
236
|
+
unless valid_modes.include?(mode)
|
|
237
|
+
raise ArgumentError, "Invalid LED mode: #{mode}. Valid modes: #{valid_modes.join(', ')}"
|
|
238
|
+
end
|
|
239
|
+
|
|
240
|
+
post('/rci/system/led', { 'mode' => mode })
|
|
241
|
+
end
|
|
242
|
+
|
|
243
|
+
# Get button configuration.
|
|
244
|
+
#
|
|
245
|
+
# == Keenetic API Request
|
|
246
|
+
# GET /rci/show/button
|
|
247
|
+
#
|
|
248
|
+
# Returns information about physical buttons on the router
|
|
249
|
+
# and their configured actions.
|
|
250
|
+
#
|
|
251
|
+
# @return [Hash] Button configuration
|
|
252
|
+
# @example
|
|
253
|
+
# buttons = client.system.button_config
|
|
254
|
+
# # => { wifi: { action: "toggle" }, fn: { action: "wps" } }
|
|
255
|
+
#
|
|
256
|
+
def button_config
|
|
257
|
+
response = get('/rci/show/button')
|
|
258
|
+
normalize_button_config(response)
|
|
259
|
+
end
|
|
260
|
+
|
|
141
261
|
private
|
|
142
262
|
|
|
263
|
+
def normalize_update_info(response)
|
|
264
|
+
return {} unless response.is_a?(Hash)
|
|
265
|
+
|
|
266
|
+
result = deep_normalize_keys(response)
|
|
267
|
+
normalize_booleans(result, %i[available downloading])
|
|
268
|
+
result
|
|
269
|
+
end
|
|
270
|
+
|
|
271
|
+
def normalize_button_config(response)
|
|
272
|
+
return {} unless response.is_a?(Hash)
|
|
273
|
+
|
|
274
|
+
deep_normalize_keys(response)
|
|
275
|
+
end
|
|
276
|
+
|
|
143
277
|
def normalize_resources(response)
|
|
144
278
|
return {} unless response.is_a?(Hash)
|
|
145
279
|
|