router-cli 0.1.0__tar.gz → 0.2.0__tar.gz

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.
@@ -1,3 +1,28 @@
1
+ Metadata-Version: 2.3
2
+ Name: router-cli
3
+ Version: 0.2.0
4
+ Summary: CLI tool to manage D-Link DSL-2750U router
5
+ Keywords: router,cli,dlink,dsl-2750u,network,admin
6
+ Author: d3vr
7
+ Author-email: d3vr <hi@f3.al>
8
+ License: MIT
9
+ Classifier: Development Status :: 4 - Beta
10
+ Classifier: Environment :: Console
11
+ Classifier: Intended Audience :: System Administrators
12
+ Classifier: License :: OSI Approved :: MIT License
13
+ Classifier: Operating System :: OS Independent
14
+ Classifier: Programming Language :: Python :: 3
15
+ Classifier: Programming Language :: Python :: 3.10
16
+ Classifier: Programming Language :: Python :: 3.11
17
+ Classifier: Programming Language :: Python :: 3.12
18
+ Classifier: Topic :: System :: Networking
19
+ Requires-Dist: tomli>=2.0.0 ; python_full_version < '3.11'
20
+ Requires-Python: >=3.10
21
+ Project-URL: Homepage, https://github.com/d3vr/router-cli
22
+ Project-URL: Repository, https://github.com/d3vr/router-cli
23
+ Project-URL: Issues, https://github.com/d3vr/router-cli/issues
24
+ Description-Content-Type: text/markdown
25
+
1
26
  # router-cli
2
27
 
3
28
  A command-line tool to manage and monitor D-Link DSL-2750U routers.
@@ -48,10 +73,12 @@ username = "admin"
48
73
  password = "your_password_here"
49
74
 
50
75
  # Optional: Define known devices for colorized output
76
+ # Use MAC addresses for devices with stable MACs
77
+ # Use hostnames for devices with random MACs (like Android)
51
78
  [known_devices]
52
79
  "AA:BB:CC:DD:EE:FF" = "My Phone"
53
80
  "11:22:33:44:55:66" = "Smart TV"
54
- "DE:AD:BE:EF:00:01" = "Work Laptop"
81
+ "android-abc123" = "John's Pixel"
55
82
  ```
56
83
 
57
84
  The tool searches for configuration in the following locations (in order):
@@ -179,7 +206,7 @@ Sends a reboot command to the router.
179
206
 
180
207
  ## Known Devices
181
208
 
182
- The `[known_devices]` section in your config file maps MAC addresses to friendly names. This enables:
209
+ The `[known_devices]` section in your config file maps MAC addresses or hostnames to friendly names. This enables:
183
210
 
184
211
  - **Color-coded output**: Known devices appear in green, unknown in red
185
212
  - **Friendly names**: See "My Phone" instead of a hostname or MAC address
@@ -187,11 +214,18 @@ The `[known_devices]` section in your config file maps MAC addresses to friendly
187
214
 
188
215
  ```toml
189
216
  [known_devices]
217
+ # By MAC address (for devices with stable MACs)
190
218
  "AA:BB:CC:DD:EE:FF" = "My Phone"
191
219
  "11:22:33:44:55:66" = "Smart TV"
220
+
221
+ # By hostname (for devices with random MACs, like some Android phones)
222
+ "android-abc123def" = "John's Pixel"
223
+ "Galaxy-S24" = "Sarah's Phone"
192
224
  ```
193
225
 
194
- MAC addresses are matched case-insensitively.
226
+ **MAC addresses** are identified by their format (`XX:XX:XX:XX:XX:XX`) and matched case-insensitively.
227
+
228
+ **Hostnames** are useful for Android and iOS devices that use MAC address randomization for privacy. These devices typically maintain a consistent hostname even when their MAC changes. Hostnames are also matched case-insensitively.
195
229
 
196
230
  ## Development
197
231
 
@@ -1,13 +1,3 @@
1
- Metadata-Version: 2.3
2
- Name: router-cli
3
- Version: 0.1.0
4
- Summary: CLI tool to manage D-Link DSL-2750U router
5
- Author: d3vr
6
- Author-email: d3vr <hi@f3.al>
7
- Requires-Dist: tomli>=2.0.0 ; python_full_version < '3.11'
8
- Requires-Python: >=3.10
9
- Description-Content-Type: text/markdown
10
-
11
1
  # router-cli
12
2
 
13
3
  A command-line tool to manage and monitor D-Link DSL-2750U routers.
@@ -58,10 +48,12 @@ username = "admin"
58
48
  password = "your_password_here"
59
49
 
60
50
  # Optional: Define known devices for colorized output
51
+ # Use MAC addresses for devices with stable MACs
52
+ # Use hostnames for devices with random MACs (like Android)
61
53
  [known_devices]
62
54
  "AA:BB:CC:DD:EE:FF" = "My Phone"
63
55
  "11:22:33:44:55:66" = "Smart TV"
64
- "DE:AD:BE:EF:00:01" = "Work Laptop"
56
+ "android-abc123" = "John's Pixel"
65
57
  ```
66
58
 
67
59
  The tool searches for configuration in the following locations (in order):
@@ -189,7 +181,7 @@ Sends a reboot command to the router.
189
181
 
190
182
  ## Known Devices
191
183
 
192
- The `[known_devices]` section in your config file maps MAC addresses to friendly names. This enables:
184
+ The `[known_devices]` section in your config file maps MAC addresses or hostnames to friendly names. This enables:
193
185
 
194
186
  - **Color-coded output**: Known devices appear in green, unknown in red
195
187
  - **Friendly names**: See "My Phone" instead of a hostname or MAC address
@@ -197,11 +189,18 @@ The `[known_devices]` section in your config file maps MAC addresses to friendly
197
189
 
198
190
  ```toml
199
191
  [known_devices]
192
+ # By MAC address (for devices with stable MACs)
200
193
  "AA:BB:CC:DD:EE:FF" = "My Phone"
201
194
  "11:22:33:44:55:66" = "Smart TV"
195
+
196
+ # By hostname (for devices with random MACs, like some Android phones)
197
+ "android-abc123def" = "John's Pixel"
198
+ "Galaxy-S24" = "Sarah's Phone"
202
199
  ```
203
200
 
204
- MAC addresses are matched case-insensitively.
201
+ **MAC addresses** are identified by their format (`XX:XX:XX:XX:XX:XX`) and matched case-insensitively.
202
+
203
+ **Hostnames** are useful for Android and iOS devices that use MAC address randomization for privacy. These devices typically maintain a consistent hostname even when their MAC changes. Hostnames are also matched case-insensitively.
205
204
 
206
205
  ## Development
207
206
 
@@ -0,0 +1,37 @@
1
+ [project]
2
+ name = "router-cli"
3
+ version = "0.2.0"
4
+ description = "CLI tool to manage D-Link DSL-2750U router"
5
+ readme = "README.md"
6
+ authors = [{ name = "d3vr", email = "hi@f3.al" }]
7
+ requires-python = ">=3.10"
8
+ dependencies = ["tomli>=2.0.0; python_version < '3.11'"]
9
+ license = { text = "MIT" }
10
+ keywords = ["router", "cli", "dlink", "dsl-2750u", "network", "admin"]
11
+ classifiers = [
12
+ "Development Status :: 4 - Beta",
13
+ "Environment :: Console",
14
+ "Intended Audience :: System Administrators",
15
+ "License :: OSI Approved :: MIT License",
16
+ "Operating System :: OS Independent",
17
+ "Programming Language :: Python :: 3",
18
+ "Programming Language :: Python :: 3.10",
19
+ "Programming Language :: Python :: 3.11",
20
+ "Programming Language :: Python :: 3.12",
21
+ "Topic :: System :: Networking",
22
+ ]
23
+
24
+ [project.urls]
25
+ Homepage = "https://github.com/d3vr/router-cli"
26
+ Repository = "https://github.com/d3vr/router-cli"
27
+ Issues = "https://github.com/d3vr/router-cli/issues"
28
+
29
+ [project.scripts]
30
+ router = "router_cli.main:main"
31
+
32
+ [dependency-groups]
33
+ dev = ["pytest>=8.0.0"]
34
+
35
+ [build-system]
36
+ requires = ["uv_build>=0.9.28,<0.10.0"]
37
+ build-backend = "uv_build"
@@ -0,0 +1,142 @@
1
+ """Configuration loading for router CLI."""
2
+
3
+ import sys
4
+ from pathlib import Path
5
+
6
+ if sys.version_info >= (3, 11):
7
+ import tomllib
8
+ else:
9
+ import tomli as tomllib
10
+
11
+
12
+ def get_config_paths() -> list[Path]:
13
+ """Return list of config file paths in order of priority."""
14
+ return [
15
+ Path.cwd() / "config.toml",
16
+ Path.home() / ".config" / "router" / "config.toml",
17
+ Path("/etc/router/config.toml"),
18
+ ]
19
+
20
+
21
+ def _load_config_file() -> dict | None:
22
+ """Load the raw config file if it exists."""
23
+ for config_path in get_config_paths():
24
+ if config_path.exists():
25
+ with open(config_path, "rb") as f:
26
+ return tomllib.load(f)
27
+ return None
28
+
29
+
30
+ def load_config() -> dict:
31
+ """Load configuration from TOML file.
32
+
33
+ Searches for config in:
34
+ 1. ./config.toml (current directory)
35
+ 2. ~/.config/router/config.toml
36
+ 3. /etc/router/config.toml
37
+ """
38
+ config = _load_config_file()
39
+ if config is not None:
40
+ return config.get("router", {})
41
+
42
+ raise FileNotFoundError(
43
+ "No config.toml found. Create one at ~/.config/router/config.toml with:\n"
44
+ "[router]\n"
45
+ 'ip = "192.168.1.1"\n'
46
+ 'username = "admin"\n'
47
+ 'password = "your_password"\n'
48
+ "\n"
49
+ "[known_devices]\n"
50
+ '"AA:BB:CC:DD:EE:FF" = "My Phone" # by MAC address\n'
51
+ '"android-abc123" = "Pixel Phone" # by hostname (for random MACs)'
52
+ )
53
+
54
+
55
+ def _is_mac_address(identifier: str) -> bool:
56
+ """Check if a string looks like a MAC address.
57
+
58
+ MAC addresses contain colons and are in format XX:XX:XX:XX:XX:XX
59
+ """
60
+ import re
61
+
62
+ # Match typical MAC address formats (with colons)
63
+ return bool(re.match(r"^([0-9A-Fa-f]{2}:){5}[0-9A-Fa-f]{2}$", identifier))
64
+
65
+
66
+ class KnownDevices:
67
+ """Container for known devices, supporting lookup by MAC or hostname.
68
+
69
+ This class supports devices that use random MAC addresses by allowing
70
+ hostname-based identification in addition to MAC-based.
71
+ """
72
+
73
+ def __init__(
74
+ self,
75
+ by_mac: dict[str, str] | None = None,
76
+ by_hostname: dict[str, str] | None = None,
77
+ ):
78
+ self.by_mac: dict[str, str] = by_mac or {}
79
+ self.by_hostname: dict[str, str] = by_hostname or {}
80
+
81
+ def get_alias(self, mac: str, hostname: str = "") -> str | None:
82
+ """Get alias for a device by MAC or hostname.
83
+
84
+ MAC lookup takes priority. Returns None if device is not known.
85
+ """
86
+ # First try MAC lookup (normalized to uppercase)
87
+ alias = self.by_mac.get(mac.upper())
88
+ if alias:
89
+ return alias
90
+
91
+ # Then try hostname lookup (case-insensitive)
92
+ if hostname:
93
+ alias = self.by_hostname.get(hostname.lower())
94
+ if alias:
95
+ return alias
96
+
97
+ return None
98
+
99
+ def is_known(self, mac: str, hostname: str = "") -> bool:
100
+ """Check if a device is known by MAC or hostname."""
101
+ return self.get_alias(mac, hostname) is not None
102
+
103
+ def get(self, key: str, default: str | None = None) -> str | None:
104
+ """Dict-like get for backward compatibility (MAC lookup only)."""
105
+ return self.by_mac.get(key, default)
106
+
107
+ def __contains__(self, key: str) -> bool:
108
+ """Dict-like contains for backward compatibility (MAC lookup only)."""
109
+ return key in self.by_mac
110
+
111
+
112
+ def load_known_devices() -> KnownDevices:
113
+ """Load known devices from config file.
114
+
115
+ Returns a KnownDevices object supporting lookup by MAC address or hostname.
116
+
117
+ Config format:
118
+ [known_devices]
119
+ "AA:BB:CC:DD:EE:FF" = "My Phone" # MAC-based (for stable MACs)
120
+ "android-abc123" = "John's Pixel" # Hostname-based (for random MACs)
121
+
122
+ MAC addresses are identified by format (XX:XX:XX:XX:XX:XX with colons).
123
+ Any other identifier is treated as a hostname.
124
+ """
125
+ config = _load_config_file()
126
+ if config is None:
127
+ return KnownDevices()
128
+
129
+ known = config.get("known_devices", {})
130
+
131
+ by_mac: dict[str, str] = {}
132
+ by_hostname: dict[str, str] = {}
133
+
134
+ for identifier, alias in known.items():
135
+ if _is_mac_address(identifier):
136
+ # MAC address - normalize to uppercase
137
+ by_mac[identifier.upper()] = alias
138
+ else:
139
+ # Hostname - normalize to lowercase for case-insensitive matching
140
+ by_hostname[identifier.lower()] = alias
141
+
142
+ return KnownDevices(by_mac=by_mac, by_hostname=by_hostname)
@@ -22,7 +22,7 @@ from .client import (
22
22
  Statistics,
23
23
  WirelessClient,
24
24
  )
25
- from .config import load_config, load_known_devices
25
+ from .config import KnownDevices, load_config, load_known_devices
26
26
 
27
27
 
28
28
  # ANSI color codes
@@ -130,14 +130,20 @@ def spinner(message: str = "Loading..."):
130
130
 
131
131
 
132
132
  def get_device_display(
133
- mac: str, hostname: str, known_devices: dict[str, str]
133
+ mac: str, hostname: str, known_devices: KnownDevices | None
134
134
  ) -> tuple[str, bool]:
135
135
  """Get display name for a device and whether it's known.
136
136
 
137
137
  Returns (display_name, is_known) tuple.
138
138
  If known, display_name is 'Alias (hostname)'.
139
+
140
+ Supports lookup by both MAC address and hostname (for devices with
141
+ random MAC addresses like some Android phones).
139
142
  """
140
- alias = known_devices.get(mac.upper())
143
+ if known_devices is None:
144
+ return hostname or mac, False
145
+
146
+ alias = known_devices.get_alias(mac, hostname)
141
147
  if alias:
142
148
  if hostname and hostname != alias:
143
149
  return f"{alias} ({hostname})", True
@@ -220,21 +226,22 @@ def format_status(status: RouterStatus) -> str:
220
226
 
221
227
 
222
228
  def format_clients(
223
- clients: list[WirelessClient], known_devices: dict[str, str] | None = None
229
+ clients: list[WirelessClient], known_devices: KnownDevices | None = None
224
230
  ) -> str:
225
231
  """Format wireless clients for display."""
226
232
  if not clients:
227
233
  return "No wireless clients connected."
228
234
 
229
- known_devices = known_devices or {}
235
+ known_devices = known_devices or KnownDevices()
230
236
 
231
237
  # Build display data and calculate column widths
232
238
  rows = []
233
239
  for c in clients:
234
240
  assoc = "Yes" if c.associated else "No"
235
241
  auth = "Yes" if c.authorized else "No"
236
- is_known = c.mac.upper() in known_devices
237
- alias = known_devices.get(c.mac.upper(), "")
242
+ # WirelessClient doesn't have hostname, so we can only check by MAC
243
+ alias = known_devices.get_alias(c.mac, "")
244
+ is_known = alias is not None
238
245
  mac_display = f"{c.mac} ({alias})" if alias else c.mac
239
246
  rows.append((mac_display, assoc, auth, c.ssid, c.interface, is_known))
240
247
 
@@ -268,13 +275,13 @@ def format_clients(
268
275
 
269
276
 
270
277
  def format_dhcp(
271
- leases: list[DHCPLease], known_devices: dict[str, str] | None = None
278
+ leases: list[DHCPLease], known_devices: KnownDevices | None = None
272
279
  ) -> str:
273
280
  """Format DHCP leases for display."""
274
281
  if not leases:
275
282
  return "No DHCP leases."
276
283
 
277
- known_devices = known_devices or {}
284
+ known_devices = known_devices or KnownDevices()
278
285
 
279
286
  # Build display data and calculate column widths
280
287
  rows = []
@@ -421,10 +428,10 @@ def format_overview(
421
428
  clients: list[WirelessClient],
422
429
  leases: list[DHCPLease],
423
430
  stats: Statistics,
424
- known_devices: dict[str, str] | None = None,
431
+ known_devices: KnownDevices | None = None,
425
432
  ) -> str:
426
433
  """Format overview dashboard with highlights from multiple sources."""
427
- known_devices = known_devices or {}
434
+ known_devices = known_devices or KnownDevices()
428
435
  lines = [
429
436
  "=" * 60,
430
437
  f"{'ROUTER OVERVIEW':^60}",
@@ -512,7 +519,9 @@ def format_overview(
512
519
  f" Low SNR margin: {stats.adsl.downstream_snr_margin:.1f} dB (may cause disconnects)"
513
520
  )
514
521
  # Warn about unknown devices
515
- unknown_count = sum(1 for lease in leases if lease.mac.upper() not in known_devices)
522
+ unknown_count = sum(
523
+ 1 for lease in leases if not known_devices.is_known(lease.mac, lease.hostname)
524
+ )
516
525
  if unknown_count > 0:
517
526
  warnings.append(
518
527
  colorize(f" Unknown devices on network: {unknown_count}", "red")
@@ -1,18 +0,0 @@
1
- [project]
2
- name = "router-cli"
3
- version = "0.1.0"
4
- description = "CLI tool to manage D-Link DSL-2750U router"
5
- readme = "README.md"
6
- authors = [{ name = "d3vr", email = "hi@f3.al" }]
7
- requires-python = ">=3.10"
8
- dependencies = ["tomli>=2.0.0; python_version < '3.11'"]
9
-
10
- [project.scripts]
11
- router = "router_cli.main:main"
12
-
13
- [dependency-groups]
14
- dev = ["pytest>=8.0.0"]
15
-
16
- [build-system]
17
- requires = ["uv_build>=0.9.28,<0.10.0"]
18
- build-backend = "uv_build"
@@ -1,66 +0,0 @@
1
- """Configuration loading for router CLI."""
2
-
3
- import sys
4
- from pathlib import Path
5
-
6
- if sys.version_info >= (3, 11):
7
- import tomllib
8
- else:
9
- import tomli as tomllib
10
-
11
-
12
- def get_config_paths() -> list[Path]:
13
- """Return list of config file paths in order of priority."""
14
- return [
15
- Path.cwd() / "config.toml",
16
- Path.home() / ".config" / "router" / "config.toml",
17
- Path("/etc/router/config.toml"),
18
- ]
19
-
20
-
21
- def _load_config_file() -> dict | None:
22
- """Load the raw config file if it exists."""
23
- for config_path in get_config_paths():
24
- if config_path.exists():
25
- with open(config_path, "rb") as f:
26
- return tomllib.load(f)
27
- return None
28
-
29
-
30
- def load_config() -> dict:
31
- """Load configuration from TOML file.
32
-
33
- Searches for config in:
34
- 1. ./config.toml (current directory)
35
- 2. ~/.config/router/config.toml
36
- 3. /etc/router/config.toml
37
- """
38
- config = _load_config_file()
39
- if config is not None:
40
- return config.get("router", {})
41
-
42
- raise FileNotFoundError(
43
- "No config.toml found. Create one at ~/.config/router/config.toml with:\n"
44
- "[router]\n"
45
- 'ip = "192.168.1.1"\n'
46
- 'username = "admin"\n'
47
- 'password = "your_password"\n'
48
- "\n"
49
- "[known_devices]\n"
50
- '"AA:BB:CC:DD:EE:FF" = "My Phone"\n'
51
- '"11:22:33:44:55:66" = "Smart TV"'
52
- )
53
-
54
-
55
- def load_known_devices() -> dict[str, str]:
56
- """Load known devices from config file.
57
-
58
- Returns a dict mapping MAC addresses (uppercase) to aliases.
59
- """
60
- config = _load_config_file()
61
- if config is None:
62
- return {}
63
-
64
- known = config.get("known_devices", {})
65
- # Normalize MAC addresses to uppercase for case-insensitive matching
66
- return {mac.upper(): alias for mac, alias in known.items()}