Skip to content

UCIConfig API

Main configuration class for managing UCI configuration.

Overview

The UCIConfig class is the main entry point for creating OpenWRT configurations. It aggregates all configuration managers (network, wireless, DHCP, firewall).

Class Reference

UCIConfig

Main UCI configuration class.

Source code in src/wrtkit/config.py
 665
 666
 667
 668
 669
 670
 671
 672
 673
 674
 675
 676
 677
 678
 679
 680
 681
 682
 683
 684
 685
 686
 687
 688
 689
 690
 691
 692
 693
 694
 695
 696
 697
 698
 699
 700
 701
 702
 703
 704
 705
 706
 707
 708
 709
 710
 711
 712
 713
 714
 715
 716
 717
 718
 719
 720
 721
 722
 723
 724
 725
 726
 727
 728
 729
 730
 731
 732
 733
 734
 735
 736
 737
 738
 739
 740
 741
 742
 743
 744
 745
 746
 747
 748
 749
 750
 751
 752
 753
 754
 755
 756
 757
 758
 759
 760
 761
 762
 763
 764
 765
 766
 767
 768
 769
 770
 771
 772
 773
 774
 775
 776
 777
 778
 779
 780
 781
 782
 783
 784
 785
 786
 787
 788
 789
 790
 791
 792
 793
 794
 795
 796
 797
 798
 799
 800
 801
 802
 803
 804
 805
 806
 807
 808
 809
 810
 811
 812
 813
 814
 815
 816
 817
 818
 819
 820
 821
 822
 823
 824
 825
 826
 827
 828
 829
 830
 831
 832
 833
 834
 835
 836
 837
 838
 839
 840
 841
 842
 843
 844
 845
 846
 847
 848
 849
 850
 851
 852
 853
 854
 855
 856
 857
 858
 859
 860
 861
 862
 863
 864
 865
 866
 867
 868
 869
 870
 871
 872
 873
 874
 875
 876
 877
 878
 879
 880
 881
 882
 883
 884
 885
 886
 887
 888
 889
 890
 891
 892
 893
 894
 895
 896
 897
 898
 899
 900
 901
 902
 903
 904
 905
 906
 907
 908
 909
 910
 911
 912
 913
 914
 915
 916
 917
 918
 919
 920
 921
 922
 923
 924
 925
 926
 927
 928
 929
 930
 931
 932
 933
 934
 935
 936
 937
 938
 939
 940
 941
 942
 943
 944
 945
 946
 947
 948
 949
 950
 951
 952
 953
 954
 955
 956
 957
 958
 959
 960
 961
 962
 963
 964
 965
 966
 967
 968
 969
 970
 971
 972
 973
 974
 975
 976
 977
 978
 979
 980
 981
 982
 983
 984
 985
 986
 987
 988
 989
 990
 991
 992
 993
 994
 995
 996
 997
 998
 999
1000
1001
1002
1003
1004
1005
1006
1007
1008
1009
1010
1011
1012
1013
1014
1015
1016
1017
1018
1019
1020
1021
1022
1023
1024
1025
1026
1027
1028
1029
1030
1031
1032
1033
1034
1035
1036
1037
1038
1039
1040
1041
1042
1043
1044
1045
1046
1047
1048
1049
1050
1051
1052
1053
1054
1055
1056
1057
1058
1059
1060
1061
1062
1063
1064
1065
1066
1067
1068
1069
1070
1071
1072
1073
1074
1075
1076
1077
1078
1079
1080
1081
1082
1083
1084
1085
1086
1087
1088
1089
1090
1091
1092
1093
1094
1095
1096
1097
1098
1099
1100
1101
1102
1103
1104
1105
1106
1107
1108
1109
1110
1111
1112
1113
1114
1115
1116
1117
1118
1119
1120
1121
1122
1123
1124
1125
1126
1127
1128
1129
1130
1131
1132
1133
1134
1135
1136
1137
1138
1139
1140
1141
1142
1143
1144
1145
1146
1147
1148
1149
1150
1151
1152
1153
1154
1155
1156
1157
1158
1159
1160
1161
1162
1163
1164
1165
1166
1167
1168
1169
1170
1171
1172
1173
1174
1175
1176
1177
1178
1179
1180
1181
1182
1183
1184
1185
1186
1187
1188
1189
1190
1191
1192
1193
1194
1195
1196
1197
1198
1199
1200
1201
1202
1203
1204
1205
1206
1207
1208
1209
1210
1211
1212
1213
1214
1215
1216
1217
1218
1219
1220
1221
1222
1223
1224
1225
1226
1227
1228
1229
1230
1231
1232
1233
1234
1235
1236
1237
1238
1239
1240
1241
1242
1243
1244
1245
1246
1247
1248
1249
1250
1251
1252
1253
1254
1255
1256
1257
1258
1259
1260
1261
1262
1263
1264
1265
1266
1267
1268
1269
1270
1271
1272
1273
1274
1275
1276
1277
1278
1279
1280
1281
1282
1283
1284
1285
1286
1287
1288
1289
1290
1291
1292
1293
1294
1295
1296
1297
1298
1299
1300
1301
1302
1303
1304
1305
1306
1307
1308
1309
1310
1311
1312
1313
1314
1315
1316
1317
1318
1319
1320
1321
1322
1323
1324
1325
1326
1327
1328
1329
1330
1331
1332
1333
1334
1335
1336
1337
1338
1339
1340
1341
1342
1343
1344
1345
1346
1347
1348
1349
1350
1351
1352
1353
1354
1355
1356
1357
1358
1359
1360
1361
1362
1363
1364
1365
1366
1367
1368
1369
1370
1371
1372
1373
1374
1375
1376
1377
1378
1379
1380
1381
1382
1383
1384
1385
1386
1387
1388
1389
1390
1391
1392
1393
1394
1395
1396
1397
1398
1399
1400
1401
1402
1403
1404
1405
1406
1407
1408
1409
1410
1411
1412
1413
1414
1415
1416
1417
1418
1419
1420
1421
1422
1423
1424
1425
1426
1427
1428
1429
1430
1431
1432
1433
1434
1435
1436
1437
1438
1439
1440
1441
1442
1443
1444
1445
1446
1447
1448
1449
1450
1451
1452
1453
1454
1455
1456
1457
1458
1459
1460
1461
1462
1463
1464
1465
1466
1467
1468
1469
1470
1471
1472
1473
1474
1475
1476
1477
1478
1479
1480
1481
1482
1483
1484
1485
1486
1487
1488
1489
1490
1491
1492
1493
1494
1495
1496
1497
1498
1499
1500
1501
1502
1503
1504
1505
1506
1507
1508
1509
1510
1511
1512
1513
1514
1515
1516
1517
1518
1519
1520
1521
1522
1523
1524
1525
1526
1527
1528
1529
1530
1531
1532
1533
1534
1535
1536
1537
1538
1539
1540
1541
1542
1543
1544
1545
1546
1547
1548
1549
1550
1551
1552
1553
1554
1555
1556
1557
1558
1559
1560
1561
1562
1563
1564
1565
1566
1567
1568
1569
1570
1571
1572
1573
1574
1575
1576
1577
1578
1579
1580
1581
1582
1583
1584
1585
1586
1587
1588
1589
1590
1591
1592
1593
1594
1595
1596
1597
1598
1599
1600
1601
1602
1603
1604
1605
1606
1607
1608
1609
1610
1611
1612
1613
1614
1615
1616
1617
1618
1619
1620
1621
1622
1623
1624
1625
1626
1627
1628
1629
1630
1631
1632
1633
1634
1635
1636
1637
1638
1639
1640
1641
1642
1643
1644
1645
1646
1647
1648
1649
1650
1651
1652
1653
1654
1655
1656
1657
1658
1659
1660
1661
1662
1663
1664
1665
1666
1667
1668
1669
1670
1671
1672
1673
1674
1675
1676
1677
1678
1679
1680
1681
1682
1683
1684
1685
1686
1687
1688
1689
1690
1691
1692
1693
1694
1695
1696
1697
1698
1699
1700
1701
1702
1703
1704
1705
1706
1707
1708
1709
1710
1711
1712
1713
1714
1715
1716
1717
1718
1719
1720
1721
1722
1723
1724
1725
1726
1727
1728
1729
1730
1731
1732
1733
1734
1735
1736
1737
1738
1739
1740
1741
1742
1743
1744
1745
1746
1747
1748
1749
1750
1751
1752
1753
1754
1755
1756
1757
1758
1759
1760
1761
1762
1763
1764
1765
1766
1767
1768
1769
1770
1771
1772
1773
1774
1775
1776
1777
1778
1779
1780
1781
1782
1783
1784
1785
1786
1787
1788
1789
1790
1791
1792
1793
1794
1795
1796
1797
1798
1799
1800
1801
1802
1803
1804
1805
1806
1807
1808
1809
1810
1811
1812
1813
1814
1815
1816
1817
1818
1819
1820
1821
1822
1823
1824
1825
1826
1827
1828
1829
1830
1831
1832
1833
1834
1835
1836
1837
1838
1839
1840
1841
1842
1843
1844
1845
1846
1847
1848
1849
1850
1851
1852
1853
1854
1855
1856
1857
1858
1859
1860
1861
1862
1863
1864
1865
1866
1867
1868
1869
1870
1871
1872
1873
1874
1875
1876
1877
1878
1879
1880
1881
1882
1883
1884
1885
1886
1887
1888
1889
1890
1891
1892
1893
1894
1895
1896
1897
1898
1899
1900
1901
1902
1903
1904
1905
1906
1907
1908
1909
1910
1911
1912
1913
1914
1915
1916
1917
1918
1919
1920
1921
1922
1923
1924
1925
1926
1927
1928
1929
1930
1931
class UCIConfig:
    """Main UCI configuration class."""

    def __init__(self) -> None:
        self.network = NetworkConfig()
        self.wireless = WirelessConfig()
        self.dhcp = DHCPConfig()
        self.firewall = FirewallConfig()
        self.sqm = SQMConfig()

    # Convenience methods to maintain backward compatibility
    def add_network_interface(self, interface: "NetworkInterface") -> "UCIConfig":
        """Add a network interface to the configuration."""
        self.network.add_interface(interface)
        return self

    def add_network_device(self, device: "NetworkDevice") -> "UCIConfig":
        """Add a network device to the configuration."""
        self.network.add_device(device)
        return self

    def add_wireless_radio(self, radio: "WirelessRadio") -> "UCIConfig":
        """Add a wireless radio to the configuration."""
        self.wireless.add_radio(radio)
        return self

    def add_wireless_interface(self, interface: "WirelessInterface") -> "UCIConfig":
        """Add a wireless interface to the configuration."""
        self.wireless.add_interface(interface)
        return self

    def add_dhcp_section(self, dhcp: "DHCPSection") -> "UCIConfig":
        """Add a DHCP section to the configuration."""
        self.dhcp.add_dhcp(dhcp)
        return self

    def add_firewall_zone(self, zone: "FirewallZone") -> "UCIConfig":
        """Add a firewall zone to the configuration."""
        self.firewall.add_zone(zone)
        return self

    def add_firewall_forwarding(self, forwarding: "FirewallForwarding") -> "UCIConfig":
        """Add a firewall forwarding rule to the configuration."""
        self.firewall.add_forwarding(forwarding)
        return self

    def add_sqm_queue(self, queue: "SQMQueue") -> "UCIConfig":
        """Add an SQM queue to the configuration."""
        self.sqm.add_queue(queue)
        return self

    def get_remote_policy(self, package: str) -> Optional[RemotePolicy]:
        """
        Get the remote policy for a specific package.

        Args:
            package: The package name (e.g., "network", "wireless", "dhcp", "firewall", "sqm")

        Returns:
            The RemotePolicy for the package, or None if not set
        """
        if package == "network":
            return self.network.remote_policy
        elif package == "wireless":
            return self.wireless.remote_policy
        elif package == "dhcp":
            return self.dhcp.remote_policy
        elif package == "firewall":
            return self.firewall.remote_policy
        elif package == "sqm":
            return self.sqm.remote_policy
        return None

    @staticmethod
    def _get_anonymous_section_types(commands: List[UCICommand]) -> List[tuple[str, str]]:
        """Get unique (package, section_type) pairs for anonymous sections.

        Returns them in the order first encountered, deduplicated.
        """
        seen: set[tuple[str, str]] = set()
        result: list[tuple[str, str]] = []
        for cmd in commands:
            if cmd.action != "set":
                continue
            parts = cmd.path.split(".")
            if len(parts) == 2 and parts[1].startswith("@") and cmd.value:
                key = (parts[0], cmd.value)
                if key not in seen:
                    seen.add(key)
                    result.append(key)
        return result

    def get_all_commands(self) -> List[UCICommand]:
        """Get all UCI commands from all configuration sections."""
        commands = []
        commands.extend(self.network.get_commands())
        commands.extend(self.wireless.get_commands())
        commands.extend(self.dhcp.get_commands())
        commands.extend(self.firewall.get_commands())
        commands.extend(self.sqm.get_commands())
        return commands

    def to_script(self, include_commit: bool = True, include_reload: bool = True) -> str:
        """
        Generate a shell script with all UCI commands.

        Args:
            include_commit: Whether to include 'uci commit' command
            include_reload: Whether to include network restart and wifi reload

        Returns:
            A shell script as a string
        """
        lines = ["#!/bin/sh", ""]

        commands = self.get_all_commands()

        # Delete existing anonymous sections before re-creating them
        anon_types = self._get_anonymous_section_types(commands)
        for package, section_type in anon_types:
            lines.append(f"while uci -q delete {package}.@{section_type}[-1]; do :; done")
        if anon_types:
            lines.append("")

        for cmd in commands:
            lines.append(cmd.to_string())

        if include_commit:
            lines.append("")
            lines.append("uci commit")

        if include_reload:
            lines.append("/etc/init.d/network restart")
            lines.append("wifi reload")
            lines.append("/etc/init.d/dnsmasq restart")

        return "\n".join(lines)

    def _parse_uci_export_format(self, package: str, config_str: str) -> List[UCICommand]:
        """Parse UCI export format: package.section.option='value'"""
        commands = []
        for line in config_str.strip().split("\n"):
            if not line or line.startswith("#"):
                continue
            # UCI export format: package.section=type or package.section.option=value
            if "=" in line:
                parts = line.split("=", 1)
                path = parts[0].strip()
                value = parts[1].strip().strip("'\"")

                # Determine if this is a section or option
                path_parts = path.split(".")
                if len(path_parts) == 2:
                    # Section definition
                    commands.append(UCICommand("set", path, value))
                elif len(path_parts) == 3:
                    # Option
                    commands.append(UCICommand("set", path, value))
        return commands

    def _parse_uci_show_format(self, package: str, config_str: str) -> List[UCICommand]:
        """
        Parse UCI show format:
        config interface 'loopback'
            option device 'lo'
            option proto 'static'
            list ipaddr '127.0.0.1/8'

        Also handles anonymous sections (no quoted name):
        config zone
            option name 'lan'
        """
        commands = []
        current_section = None
        # Track anonymous section counts by type for @type[N] indexing
        anon_counts: Dict[str, int] = {}

        for line in config_str.strip().split("\n"):
            line = line.rstrip()
            if not line or line.startswith("package "):
                continue

            # Section definition: config <type> '<name>' or config <type> (anonymous)
            if line.startswith("config "):
                parts = line.split("'")
                if len(parts) >= 2:
                    # Named section: config interface 'loopback'
                    section_name = parts[1]
                    section_type = line.split()[1].strip("'")
                    current_section = section_name
                    commands.append(UCICommand("set", f"{package}.{section_name}", section_type))
                else:
                    # Anonymous section: config zone (no quoted name)
                    section_type = line.split()[1].strip()
                    idx = anon_counts.get(section_type, 0)
                    anon_counts[section_type] = idx + 1
                    current_section = f"@{section_type}[{idx}]"
                    commands.append(UCICommand("set", f"{package}.{current_section}", section_type))

            # Option: \toption <name> '<value>'
            elif line.startswith("\toption ") and current_section:
                parts = line.strip().split("'")
                if len(parts) >= 2:
                    option_name = line.split()[1].strip("'")
                    option_value = parts[1]
                    commands.append(
                        UCICommand(
                            "set", f"{package}.{current_section}.{option_name}", option_value
                        )
                    )

            # List: \tlist <name> '<value>'
            elif line.startswith("\tlist ") and current_section:
                parts = line.strip().split("'")
                if len(parts) >= 2:
                    list_name = line.split()[1].strip("'")
                    list_value = parts[1]
                    # For lists, we use add_list command
                    commands.append(
                        UCICommand(
                            "add_list", f"{package}.{current_section}.{list_name}", list_value
                        )
                    )

        return commands

    def _parse_remote_config(
        self, ssh: SSHConnection, spinner: Optional[Spinner] = None
    ) -> List[UCICommand]:
        """
        Parse the remote UCI configuration into commands.

        Handles both 'uci export' and 'uci show' format.

        Args:
            ssh: SSH connection to the remote device
            spinner: Optional spinner to update with progress
        """
        commands = []
        packages = ["network", "wireless", "dhcp", "firewall", "sqm"]
        # Optional packages that don't require warnings if missing
        optional_packages = ["sqm"]

        for package in packages:
            if spinner:
                spinner.update(f"Fetching {package} config...")
            try:
                config_str = ssh.get_uci_config(package)

                # Detect format: 'uci export' uses = syntax, 'uci show' uses 'config'/'option' syntax
                if "config " in config_str or "\toption " in config_str:
                    # UCI show format
                    commands.extend(self._parse_uci_show_format(package, config_str))
                else:
                    # UCI export format
                    commands.extend(self._parse_uci_export_format(package, config_str))

            except Exception as e:
                # If we can't get a package, just skip it
                # Only show warning for non-optional packages
                if package not in optional_packages:
                    print(f"Warning: Could not retrieve {package} config: {e}")
                continue

        return commands

    def _get_logical_path(self, uci_path: str, section_types: Dict[str, str], package: str) -> str:
        """
        Convert a UCI path to a logical path for whitelist matching.

        Args:
            uci_path: The UCI path (e.g., "network.br_lan.ports")
            section_types: Mapping of section names to their types
            package: The package name

        Returns:
            Logical path (e.g., "devices.br_lan.ports" or "interfaces.lan.gateway")
        """
        parts = uci_path.split(".")
        if len(parts) < 2:
            return uci_path

        # Skip the package prefix to get relative path
        section_name = parts[1]
        section_type = section_types.get(section_name, "")

        # Map section type to logical prefix for network package
        if package == "network":
            if section_type == "device":
                logical_prefix = "devices"
            elif section_type == "interface":
                logical_prefix = "interfaces"
            else:
                # Unknown type, use section name directly
                return ".".join(parts[1:])
        elif package == "dhcp":
            if section_type == "host":
                logical_prefix = "hosts"
            elif section_type == "dhcp":
                logical_prefix = "sections"
            elif section_type == "dnsmasq":
                logical_prefix = "dnsmasq"
            else:
                return ".".join(parts[1:])
        elif package == "wireless":
            if section_type == "wifi-device":
                logical_prefix = "radios"
            elif section_type == "wifi-iface":
                logical_prefix = "interfaces"
            else:
                return ".".join(parts[1:])
        elif package == "firewall":
            if section_type == "zone":
                logical_prefix = "zones"
            elif section_type == "forwarding":
                logical_prefix = "forwardings"
            elif section_type == "defaults":
                logical_prefix = "defaults"
            elif section_type == "rule":
                logical_prefix = "rules"
            else:
                return ".".join(parts[1:])
        elif package == "sqm":
            if section_type == "queue":
                logical_prefix = "queues"
            else:
                return ".".join(parts[1:])
        else:
            # Other packages use direct section names
            return ".".join(parts[1:])

        # Construct logical path: logical_prefix.section_name.option...
        if len(parts) == 2:
            # Section definition (no option)
            return f"{logical_prefix}.{section_name}"
        else:
            # Section with option(s)
            return f"{logical_prefix}.{section_name}.{'.'.join(parts[2:])}"

    def diff(
        self,
        ssh: SSHConnection,
        show_remote_only: bool = True,
        remove_packages: Optional[List[str]] = None,
        verbose: bool = False,
    ) -> ConfigDiff:
        """
        Compare this configuration with the remote device configuration.

        Args:
            ssh: SSH connection to the remote device
            show_remote_only: If True, track UCI settings on remote but not mentioned in local config.
                              If False, all remote-only settings go to to_remove.
            remove_packages: Optional list of packages for which remote-only settings should be
                             marked for removal (e.g., ["network", "wireless"]). When specified,
                             only these packages' remote-only settings go to to_remove, others
                             go to remote_only. This overrides show_remote_only for the
                             specified packages.
            verbose: If True, show progress spinner while fetching remote config

        Returns:
            A ConfigDiff object describing the differences
        """
        local_commands = self.get_all_commands()

        if verbose:
            spinner = Spinner("Fetching remote configuration...")
            spinner.start()
            try:
                remote_commands = self._parse_remote_config(ssh, spinner=spinner)
                spinner.stop("✓ Remote configuration fetched")
            except Exception:
                spinner.stop("✗ Failed to fetch remote configuration")
                raise
        else:
            remote_commands = self._parse_remote_config(ssh)

        diff = ConfigDiff()

        # Build section type mapping from commands (for logical path construction)
        section_types: Dict[str, Dict[str, str]] = {}  # {package: {section_name: section_type}}
        for cmd in local_commands + remote_commands:
            parts = cmd.path.split(".")
            if len(parts) == 2 and cmd.action == "set":
                # This is a section definition: package.section = type
                package = parts[0]
                section_name = parts[1]
                section_type = cmd.value if cmd.value else ""
                if package not in section_types:
                    section_types[package] = {}
                section_types[package][section_name] = section_type

        # Build section-level tracking for tree display
        for cmd in local_commands:
            parts = cmd.path.split(".")
            if len(parts) >= 2:
                diff._local_sections.add((parts[0], parts[1]))

        for cmd in remote_commands:
            parts = cmd.path.split(".")
            if len(parts) >= 2:
                diff._remote_sections.add((parts[0], parts[1]))

        # Normalize format mismatches: when local uses 'set' with a space-joined
        # value (e.g. set firewall.wan.network='wan wan6') but remote has the same
        # data as 'add_list' entries, collapse remote add_list into a matching set.
        local_set_paths: dict[str, str] = {}
        for cmd in local_commands:
            if cmd.action == "set" and cmd.value and " " in cmd.value:
                local_set_paths[cmd.path] = cmd.value

        remote_add_list_groups: dict[str, list[str]] = {}
        for cmd in remote_commands:
            if cmd.action == "add_list" and cmd.path in local_set_paths:
                remote_add_list_groups.setdefault(cmd.path, []).append(cmd.value or "")

        if remote_add_list_groups:
            normalized_remote: list[UCICommand] = []
            for cmd in remote_commands:
                if cmd.action == "add_list" and cmd.path in remote_add_list_groups:
                    continue  # skip individual add_list entries; replaced below
                normalized_remote.append(cmd)
            for path, values in remote_add_list_groups.items():
                normalized_remote.append(UCICommand("set", path, " ".join(values)))
            remote_commands = normalized_remote

        # Create sets for comparison
        # For add_list commands, we compare (path, value) pairs
        # For set commands, we track path -> value mapping
        local_set = {(cmd.path, cmd.value) for cmd in local_commands}
        remote_set = {(cmd.path, cmd.value) for cmd in remote_commands}

        local_paths = {c.path for c in local_commands}

        # Commands in local but not in remote
        for cmd in local_commands:
            key = (cmd.path, cmd.value)
            if key not in remote_set:
                # For add_list commands, if the (path, value) pair doesn't exist, it's an addition
                if cmd.action == "add_list":
                    diff.to_add.append(cmd)
                else:
                    # For set commands, check if path exists in remote with different value
                    remote_paths = {c.path for c in remote_commands if c.action == "set"}
                    if cmd.path in remote_paths:
                        # Find the remote command with same path
                        remote_cmd = next(
                            c for c in remote_commands if c.path == cmd.path and c.action == "set"
                        )
                        diff.to_modify.append((remote_cmd, cmd))
                    else:
                        diff.to_add.append(cmd)
            else:
                # Setting exists in both with same value
                diff.common.append(cmd)

        # Commands in remote but not in local
        for cmd in remote_commands:
            key = (cmd.path, cmd.value)
            if key not in local_set:
                # Determine if this command should be marked for removal
                parts = cmd.path.split(".")
                cmd_package = parts[0]
                cmd_section = parts[1] if len(parts) >= 2 else ""
                should_remove = False

                # Check if this section exists in local config
                section_in_local = (cmd_package, cmd_section) in diff._local_sections
                section_is_remote_only = not section_in_local

                # Check if the package has a remote policy
                remote_policy = self.get_remote_policy(cmd_package)
                is_whitelisted = False

                if remote_policy is not None:
                    # Remote policy controls which REMOTE-ONLY sections/values to keep
                    if section_is_remote_only:
                        # Section only exists on remote - apply remote policy
                        # Use new whitelist approach if configured, otherwise fall back to legacy
                        if remote_policy.whitelist:
                            # New whitelist approach - construct logical path
                            pkg_section_types = section_types.get(cmd_package, {})
                            logical_path = self._get_logical_path(
                                cmd.path, pkg_section_types, cmd_package
                            )
                            if remote_policy.should_keep_remote_path(logical_path):
                                is_whitelisted = True
                            else:
                                should_remove = True
                        else:
                            # Legacy behavior for backward compatibility
                            if cmd.action == "add_list":
                                # For list values, check both section and value
                                if not remote_policy.should_keep_remote_value(
                                    cmd_section, str(cmd.value)
                                ):
                                    should_remove = True
                            else:
                                # For regular settings, check if section is allowed
                                if not remote_policy.should_keep_remote_section(cmd_section):
                                    should_remove = True
                    else:
                        # Section exists in both local and remote
                        # Remote options not in local should be removed
                        # UNLESS they are whitelisted
                        if remote_policy.whitelist:
                            # Check if this specific path is whitelisted
                            pkg_section_types = section_types.get(cmd_package, {})
                            logical_path = self._get_logical_path(
                                cmd.path, pkg_section_types, cmd_package
                            )
                            if remote_policy.should_keep_remote_path(logical_path):
                                is_whitelisted = True
                            else:
                                should_remove = True
                        else:
                            # Legacy behavior: always remove remote options in locally-managed sections
                            should_remove = True
                elif remove_packages is not None:
                    # Per-package removal: only remove if package is in the list
                    should_remove = cmd_package in remove_packages
                elif not show_remote_only:
                    # Global removal: remove all remote-only
                    should_remove = True

                # Categorize the command based on what should happen to it
                if cmd.action == "add_list":
                    if should_remove:
                        diff.to_remove.append(cmd)
                    elif is_whitelisted:
                        diff.whitelisted.append(cmd)
                    else:
                        diff.remote_only.append(cmd)
                else:
                    # For set commands, check if path exists in local
                    if cmd.path not in local_paths:
                        # Path doesn't exist in local config at all
                        if should_remove:
                            diff.to_remove.append(cmd)
                        elif is_whitelisted:
                            diff.whitelisted.append(cmd)
                        else:
                            diff.remote_only.append(cmd)

        return diff

    def apply(
        self,
        ssh: SSHConnection,
        dry_run: bool = False,
        auto_commit: bool = True,
        auto_reload: bool = True,
        verbose: bool = False,
    ) -> None:
        """
        Apply this configuration to a remote device.

        Args:
            ssh: SSH connection to the remote device
            dry_run: If True, only show what would be done
            auto_commit: If True, automatically commit changes
            auto_reload: If True, automatically reload network and wireless
            verbose: If True, show progress spinner and status messages
        """
        commands = self.get_all_commands()

        # Determine which packages are being configured
        changed_packages: set[str] = set()
        for cmd in commands:
            parts = cmd.path.split(".")
            if parts:
                changed_packages.add(parts[0])

        # Identify anonymous section types that need cleanup before re-creation
        anon_types = self._get_anonymous_section_types(commands)

        if dry_run:
            print("[Dry run mode - no changes made]")
            if not commands:
                print("No commands to execute.")
                return
            for package, section_type in anon_types:
                print(f"Would run: while uci -q delete {package}.@{section_type}[-1]; do :; done")
            for cmd in commands:
                print(f"Would run: {cmd.to_string()}")
            if auto_commit and commands:
                print("Would run: uci commit")
            if auto_reload and commands:
                # Show only relevant restart commands based on packages being configured
                if "network" in changed_packages or "sqm" in changed_packages:
                    print("Would run: /etc/init.d/network restart")
                if "wireless" in changed_packages:
                    print("Would run: wifi reload")
                if "dhcp" in changed_packages:
                    print("Would run: /etc/init.d/dnsmasq restart")
                if "firewall" in changed_packages:
                    print("Would run: /etc/init.d/firewall reload")
            return

        # Calculate total steps (includes cleanup commands)
        total_steps = (
            len(anon_types) + len(commands) + (1 if auto_commit else 0) + (1 if auto_reload else 0)
        )

        if verbose and total_steps > 0:
            progress = ProgressBar(total_steps, "Applying configuration")
            progress._render()
        else:
            progress = None

        try:
            # Delete existing anonymous sections before re-creating them
            for package, section_type in anon_types:
                if progress:
                    progress.update(message=f"Cleaning {package}.@{section_type}")
                cleanup_cmd = f"while uci -q delete {package}.@{section_type}[-1]; do :; done"
                ssh.execute_uci_command(cleanup_cmd)

            # Execute commands
            for cmd in commands:
                if progress:
                    parts = cmd.path.split(".")
                    if len(parts) >= 2:
                        progress.update(message=f"Applying {parts[0]}.{parts[1]}")
                    else:
                        progress.update()

                stdout, stderr, exit_code = ssh.execute_uci_command(cmd.to_string())
                if exit_code != 0:
                    if progress:
                        progress.finish(f"✗ Failed at command: {cmd.to_string()}")
                    raise RuntimeError(f"Failed to execute command '{cmd.to_string()}': {stderr}")

            # Commit changes
            if auto_commit:
                if progress:
                    progress.update(message="Committing changes")
                ssh.commit_changes()

            # Reload configuration (only services for changed packages)
            if auto_reload:
                if progress:
                    progress.update(message="Reloading services")
                ssh.reload_config(changed_packages=changed_packages)

            if progress:
                progress.finish(f"✓ Applied {len(commands)} commands")

        except Exception:
            if progress:
                progress.finish("✗ Failed to apply configuration")
            raise

    def apply_diff(
        self,
        ssh: SSHConnection,
        remove_unmanaged: Union[bool, List[str]] = False,
        dry_run: bool = False,
        auto_commit: bool = True,
        auto_reload: bool = True,
        verbose: bool = False,
        filter_pattern: Optional[str] = None,
    ) -> ConfigDiff:
        """
        Apply only the differences between this config and the remote device.

        This method first computes the diff, then applies only the necessary changes.
        Optionally can remove settings that exist on the remote but are not defined
        in this configuration.

        Args:
            ssh: SSH connection to the remote device
            remove_unmanaged: Controls removal of settings on remote not in config.
                - False (default): Don't remove any unmanaged settings
                - True: Remove ALL unmanaged settings (use with caution!)
                - List of packages: Remove unmanaged settings only for specified packages.
                  Valid packages: "network", "wireless", "dhcp", "firewall"
                  Example: ["network", "wireless"] removes unmanaged network and wireless
                  settings but keeps unmanaged dhcp and firewall settings.
            dry_run: If True, only show what would be done without making changes
            auto_commit: If True, automatically commit changes
            auto_reload: If True, automatically reload network and wireless
            verbose: If True, show progress spinner and status messages
            filter_pattern: Glob pattern to filter which changes to apply.
                Only changes matching the pattern will be applied.
                Examples: "network.interfaces.*", "network.interfaces.wan.*"

        Returns:
            The ConfigDiff object showing what was (or would be) applied

        Examples:
            # Only apply additions and modifications, keep all remote-only settings
            config.apply_diff(ssh)

            # Remove ALL unmanaged settings (dangerous!)
            config.apply_diff(ssh, remove_unmanaged=True)

            # Remove unmanaged wireless interfaces but keep everything else
            config.apply_diff(ssh, remove_unmanaged=["wireless"])

            # Remove unmanaged network and wireless settings
            config.apply_diff(ssh, remove_unmanaged=["network", "wireless"])

            # Show progress during apply
            config.apply_diff(ssh, verbose=True)

            # Apply only network interface changes
            config.apply_diff(ssh, filter_pattern="network.interfaces.*")
        """
        # Determine how to handle remote-only items
        remove_packages: Optional[List[str]] = None
        if isinstance(remove_unmanaged, list):
            remove_packages = remove_unmanaged
            # Get the diff with per-package removal
            diff = self.diff(ssh, remove_packages=remove_packages, verbose=verbose)
        elif remove_unmanaged:
            # Remove all unmanaged
            diff = self.diff(ssh, show_remote_only=False, verbose=verbose)
        else:
            # Don't remove anything
            diff = self.diff(ssh, show_remote_only=True, verbose=verbose)

        # Apply filter if specified
        if filter_pattern:
            diff = diff.filter_by_pattern(filter_pattern)

        if diff.is_empty() and not diff.to_remove:
            if not dry_run:
                print("No changes to apply.")
            return diff

        # Build list of commands to execute
        commands_to_run: List[UCICommand] = []

        # First, handle removals (delete commands should come first)
        if remove_unmanaged and diff.to_remove:
            commands_to_run.extend(diff.get_removal_commands())

        # Add new settings
        commands_to_run.extend(diff.to_add)

        # Modify existing settings (just apply the new values)
        for old_cmd, new_cmd in diff.to_modify:
            commands_to_run.append(new_cmd)

        # Get which packages have changes for targeted service restarts
        changed_packages = diff.get_changed_packages()

        if dry_run:
            print("[Dry run mode - no changes made]")
            if not commands_to_run:
                print("No commands to execute.")
                return diff
            for cmd in commands_to_run:
                print(f"Would run: {cmd.to_string()}")
            if auto_commit and commands_to_run:
                print("Would run: uci commit")
            if auto_reload and commands_to_run:
                # Show only relevant restart commands based on changed packages
                if "network" in changed_packages or "sqm" in changed_packages:
                    print("Would run: /etc/init.d/network restart")
                if "wireless" in changed_packages:
                    print("Would run: wifi reload")
                if "dhcp" in changed_packages:
                    print("Would run: /etc/init.d/dnsmasq restart")
                if "firewall" in changed_packages:
                    print("Would run: /etc/init.d/firewall reload")
            return diff

        # If no commands to run, skip commit and reload
        if not commands_to_run:
            print("No changes to apply.")
            return diff

        # Execute commands with progress
        total_steps = len(commands_to_run) + (1 if auto_commit else 0) + (1 if auto_reload else 0)

        if verbose and total_steps > 0:
            progress = ProgressBar(total_steps, "Applying configuration")
            progress._render()
        else:
            progress = None

        try:
            for i, cmd in enumerate(commands_to_run):
                if progress:
                    # Extract a short description of what we're doing
                    parts = cmd.path.split(".")
                    if len(parts) >= 2:
                        progress.update(message=f"Applying {parts[0]}.{parts[1]}")
                    else:
                        progress.update()

                stdout, stderr, exit_code = ssh.execute_uci_command(cmd.to_string())
                if exit_code != 0:
                    if progress:
                        progress.finish(f"✗ Failed at command: {cmd.to_string()}")
                    raise RuntimeError(f"Failed to execute command '{cmd.to_string()}': {stderr}")

            # Commit changes (only if we actually ran commands)
            if auto_commit:
                if progress:
                    progress.update(message="Committing changes")
                ssh.commit_changes()

            # Reload configuration (only services for changed packages)
            if auto_reload:
                if progress:
                    progress.update(message="Reloading services")
                ssh.reload_config(changed_packages=changed_packages)

            if progress:
                progress.finish(f"✓ Applied {len(commands_to_run)} changes")

        except Exception:
            if progress:
                progress.finish("✗ Failed to apply configuration")
            raise

        return diff

    def save_to_file(
        self, filename: str, include_commit: bool = True, include_reload: bool = True
    ) -> None:
        """
        Save the configuration to a shell script file.

        Args:
            filename: Path to the output file
            include_commit: Whether to include 'uci commit' command
            include_reload: Whether to include network restart and wifi reload
        """
        script = self.to_script(include_commit=include_commit, include_reload=include_reload)
        with open(filename, "w") as f:
            f.write(script)

    # YAML/JSON Schema generation
    @classmethod
    def json_schema(cls, title: str = "UCI Configuration Schema") -> Dict[str, Any]:
        """
        Generate JSON Schema for the complete UCI configuration.

        Args:
            title: Title for the schema

        Returns:
            JSON Schema as a dictionary
        """
        return {
            "title": title,
            "type": "object",
            "properties": {
                "network": {
                    "type": "object",
                    "properties": {
                        "devices": {
                            "type": "object",
                            "additionalProperties": NetworkDevice.json_schema(),
                        },
                        "interfaces": {
                            "type": "object",
                            "additionalProperties": NetworkInterface.json_schema(),
                        },
                    },
                },
                "wireless": {
                    "type": "object",
                    "properties": {
                        "radios": {
                            "type": "object",
                            "additionalProperties": WirelessRadio.json_schema(),
                        },
                        "interfaces": {
                            "type": "object",
                            "additionalProperties": WirelessInterface.json_schema(),
                        },
                    },
                },
                "dhcp": {
                    "type": "object",
                    "properties": {
                        "dnsmasq": DnsmasqSection.json_schema(),
                        "sections": {
                            "type": "object",
                            "additionalProperties": DHCPSection.json_schema(),
                        },
                        "hosts": {
                            "type": "object",
                            "additionalProperties": DHCPHost.json_schema(),
                        },
                    },
                },
                "firewall": {
                    "type": "object",
                    "properties": {
                        "zones": {
                            "type": "object",
                            "additionalProperties": FirewallZone.json_schema(),
                        },
                        "forwardings": {"type": "array", "items": FirewallForwarding.json_schema()},
                    },
                },
                "sqm": {
                    "type": "object",
                    "properties": {
                        "queues": {"type": "object", "additionalProperties": SQMQueue.json_schema()}
                    },
                },
            },
        }

    @classmethod
    def yaml_schema(cls, title: str = "UCI Configuration Schema") -> str:
        """
        Generate YAML Schema for the complete UCI configuration.

        Args:
            title: Title for the schema

        Returns:
            JSON Schema in YAML format as a string
        """
        schema = cls.json_schema(title)
        return yaml.dump(schema, default_flow_style=False, sort_keys=False)

    # Serialization methods
    def to_dict(self, exclude_none: bool = True) -> Dict[str, Any]:
        """
        Convert the configuration to a dictionary.

        Args:
            exclude_none: Whether to exclude None values

        Returns:
            Dictionary representation of the configuration
        """
        result: Dict[str, Any] = {}

        # Network configuration
        if self.network.devices or self.network.interfaces or self.network.bridge_vlans:
            network_dict: Dict[str, Any] = {}

            if self.network.devices:
                devices_dict = {}
                for device in self.network.devices:
                    devices_dict[device._section] = device.to_dict(exclude_none=exclude_none)
                network_dict["devices"] = devices_dict

            if self.network.interfaces:
                interfaces_dict = {}
                for interface in self.network.interfaces:
                    interfaces_dict[interface._section] = interface.to_dict(
                        exclude_none=exclude_none
                    )
                network_dict["interfaces"] = interfaces_dict

            if self.network.bridge_vlans:
                bridge_vlans_dict = {}
                for bridge_vlan in self.network.bridge_vlans:
                    bridge_vlans_dict[bridge_vlan._section] = bridge_vlan.to_dict(
                        exclude_none=exclude_none
                    )
                network_dict["bridge_vlans"] = bridge_vlans_dict

            result["network"] = network_dict

        # Wireless configuration
        if self.wireless.radios or self.wireless.interfaces:
            wireless_dict: Dict[str, Any] = {}

            if self.wireless.radios:
                radios_dict = {}
                for radio in self.wireless.radios:
                    radios_dict[radio._section] = radio.to_dict(exclude_none=exclude_none)
                wireless_dict["radios"] = radios_dict

            if self.wireless.interfaces:
                interfaces_dict = {}
                for iface in self.wireless.interfaces:
                    interfaces_dict[iface._section] = iface.to_dict(exclude_none=exclude_none)
                wireless_dict["interfaces"] = interfaces_dict

            result["wireless"] = wireless_dict

        # DHCP configuration
        if self.dhcp.sections or self.dhcp.hosts or self.dhcp.dnsmasq:
            dhcp_dict: Dict[str, Any] = {}
            if self.dhcp.dnsmasq:
                dhcp_dict["dnsmasq"] = self.dhcp.dnsmasq.to_dict(exclude_none=exclude_none)
            if self.dhcp.sections:
                sections_dict = {}
                for section in self.dhcp.sections:
                    sections_dict[section._section] = section.to_dict(exclude_none=exclude_none)
                dhcp_dict["sections"] = sections_dict
            if self.dhcp.hosts:
                hosts_dict = {}
                for host in self.dhcp.hosts:
                    hosts_dict[host._section] = host.to_dict(exclude_none=exclude_none)
                dhcp_dict["hosts"] = hosts_dict
            result["dhcp"] = dhcp_dict

        # Firewall configuration
        if self.firewall.zones or self.firewall.forwardings:
            firewall_dict: Dict[str, Any] = {}

            if self.firewall.zones:
                zones_dict = {}
                for zone in self.firewall.zones:
                    zone_name = zone.name or zone._section
                    zones_dict[zone_name] = zone.to_dict(exclude_none=exclude_none)
                firewall_dict["zones"] = zones_dict

            if self.firewall.forwardings:
                forwardings_dict = {}
                for forwarding in self.firewall.forwardings:
                    forwardings_dict[forwarding._section] = forwarding.to_dict(
                        exclude_none=exclude_none
                    )
                firewall_dict["forwardings"] = forwardings_dict

            result["firewall"] = firewall_dict

        # SQM configuration
        if self.sqm.queues:
            sqm_dict: Dict[str, Any] = {}
            queues_dict = {}
            for queue in self.sqm.queues:
                queues_dict[queue._section] = queue.to_dict(exclude_none=exclude_none)
            sqm_dict["queues"] = queues_dict
            result["sqm"] = sqm_dict

        return result

    def to_json(self, indent: int = 2, exclude_none: bool = True) -> str:
        """
        Convert the configuration to JSON string.

        Args:
            indent: Indentation level for pretty printing
            exclude_none: Whether to exclude None values

        Returns:
            JSON string representation
        """
        data = self.to_dict(exclude_none=exclude_none)
        return json.dumps(data, indent=indent)

    def to_yaml(self, exclude_none: bool = True) -> str:
        """
        Convert the configuration to YAML string.

        Args:
            exclude_none: Whether to exclude None values

        Returns:
            YAML string representation
        """
        data = self.to_dict(exclude_none=exclude_none)
        return yaml.dump(data, default_flow_style=False, sort_keys=False)

    def to_json_file(self, filename: str, indent: int = 2, exclude_none: bool = True) -> None:
        """
        Save the configuration to a JSON file.

        Args:
            filename: Path to output file
            indent: Indentation level for pretty printing
            exclude_none: Whether to exclude None values
        """
        json_str = self.to_json(indent=indent, exclude_none=exclude_none)
        with open(filename, "w") as f:
            f.write(json_str)

    def to_yaml_file(self, filename: str, exclude_none: bool = True) -> None:
        """
        Save the configuration to a YAML file.

        Args:
            filename: Path to output file
            exclude_none: Whether to exclude None values
        """
        yaml_str = self.to_yaml(exclude_none=exclude_none)
        with open(filename, "w") as f:
            f.write(yaml_str)

    # Deserialization methods
    @classmethod
    def from_dict(cls, data: Dict[str, Any]) -> "UCIConfig":
        """
        Create a UCIConfig instance from a dictionary.

        Args:
            data: Dictionary containing the configuration

        Returns:
            UCIConfig instance
        """
        config = cls()

        # Load network configuration
        if "network" in data:
            network_data = data["network"]

            if "devices" in network_data:
                for section_name, device_data in network_data["devices"].items():
                    device = NetworkDevice(section_name, **device_data)
                    config.network.add_device(device)

            if "interfaces" in network_data:
                for section_name, interface_data in network_data["interfaces"].items():
                    interface = NetworkInterface(section_name, **interface_data)
                    config.network.add_interface(interface)

            if "bridge_vlans" in network_data:
                for section_name, bridge_vlan_data in network_data["bridge_vlans"].items():
                    bridge_vlan = BridgeVLAN(section_name, **bridge_vlan_data)
                    config.network.add_bridge_vlan(bridge_vlan)

            if "remote_policy" in network_data:
                config.network.remote_policy = RemotePolicy(**network_data["remote_policy"])

        # Load wireless configuration
        if "wireless" in data:
            wireless_data = data["wireless"]

            if "radios" in wireless_data:
                for section_name, radio_data in wireless_data["radios"].items():
                    radio = WirelessRadio(section_name, **radio_data)
                    config.wireless.add_radio(radio)

            if "interfaces" in wireless_data:
                for section_name, interface_data in wireless_data["interfaces"].items():
                    wireless_iface = WirelessInterface(section_name, **interface_data)
                    config.wireless.add_interface(wireless_iface)

            if "remote_policy" in wireless_data:
                config.wireless.remote_policy = RemotePolicy(**wireless_data["remote_policy"])

        # Load DHCP configuration
        if "dhcp" in data:
            dhcp_data = data["dhcp"]

            if "dnsmasq" in dhcp_data:
                dnsmasq_data = dhcp_data["dnsmasq"]
                section_name = dnsmasq_data.pop("_name", "main")
                dnsmasq = DnsmasqSection(section_name, **dnsmasq_data)
                config.dhcp.set_dnsmasq(dnsmasq)

            if "sections" in dhcp_data:
                for section_name, section_data in dhcp_data["sections"].items():
                    section = DHCPSection(section_name, **section_data)
                    config.dhcp.add_dhcp(section)

            if "hosts" in dhcp_data:
                for host_name, host_data in dhcp_data["hosts"].items():
                    host = DHCPHost(host_name, **host_data)
                    config.dhcp.add_host(host)

            if "remote_policy" in dhcp_data:
                config.dhcp.remote_policy = RemotePolicy(**dhcp_data["remote_policy"])

        # Load firewall configuration
        if "firewall" in data:
            firewall_data = data["firewall"]

            if "zones" in firewall_data:
                for zone_name, zone_data in firewall_data["zones"].items():
                    zone_data = zone_data.copy()
                    if "name" not in zone_data:
                        zone_data["name"] = zone_name
                    zone_data.pop("index", None)
                    zone = FirewallZone(zone_name, **zone_data)
                    config.firewall.add_zone(zone)

            if "forwardings" in firewall_data:
                fwd_value = firewall_data["forwardings"]
                if isinstance(fwd_value, dict):
                    # Named dict format: forwardings: { fwd_lan_wan: {src: lan, dest: wan} }
                    for fwd_name, forwarding_data in fwd_value.items():
                        forwarding = FirewallForwarding(fwd_name, **forwarding_data)
                        config.firewall.add_forwarding(forwarding)
                else:
                    # Legacy list format: forwardings: [ {src: lan, dest: wan} ]
                    for forwarding_data in fwd_value:
                        forwarding_data = forwarding_data.copy()
                        forwarding_data.pop("index", None)
                        fwd_name = f"fwd_{forwarding_data.get('src', 'unknown')}_{forwarding_data.get('dest', 'unknown')}"
                        forwarding = FirewallForwarding(fwd_name, **forwarding_data)
                        config.firewall.add_forwarding(forwarding)

            if "remote_policy" in firewall_data:
                config.firewall.remote_policy = RemotePolicy(**firewall_data["remote_policy"])

        # Load SQM configuration
        if "sqm" in data:
            sqm_data = data["sqm"]

            if "queues" in sqm_data:
                for queue_name, queue_data in sqm_data["queues"].items():
                    queue = SQMQueue(queue_name, **queue_data)
                    config.sqm.add_queue(queue)

            if "remote_policy" in sqm_data:
                config.sqm.remote_policy = RemotePolicy(**sqm_data["remote_policy"])

        return config

    @classmethod
    def from_json(cls, json_str: str) -> "UCIConfig":
        """
        Create a UCIConfig instance from JSON string.

        Args:
            json_str: JSON string

        Returns:
            UCIConfig instance
        """
        data = json.loads(json_str)
        return cls.from_dict(data)

    @classmethod
    def from_yaml(cls, yaml_str: str) -> "UCIConfig":
        """
        Create a UCIConfig instance from YAML string.

        Args:
            yaml_str: YAML string

        Returns:
            UCIConfig instance
        """
        # Load YAML through OmegaConf for variable interpolation and other features
        omega_conf = OmegaConf.create(yaml_str)
        # Convert to regular Python dict for Pydantic validation
        data = OmegaConf.to_container(omega_conf, resolve=True)
        if not isinstance(data, dict):
            raise ValueError("YAML content must be a dictionary")
        return cls.from_dict(cast(Dict[str, Any], data))

    @classmethod
    def from_json_file(cls, filename: str) -> "UCIConfig":
        """
        Create a UCIConfig instance from JSON file.

        Args:
            filename: Path to JSON file

        Returns:
            UCIConfig instance
        """
        with open(filename, "r") as f:
            return cls.from_json(f.read())

    @classmethod
    def from_yaml_file(cls, filename: str) -> "UCIConfig":
        """
        Create a UCIConfig instance from YAML file.

        Args:
            filename: Path to YAML file

        Returns:
            UCIConfig instance
        """
        with open(filename, "r") as f:
            return cls.from_yaml(f.read())

__init__()

Source code in src/wrtkit/config.py
def __init__(self) -> None:
    self.network = NetworkConfig()
    self.wireless = WirelessConfig()
    self.dhcp = DHCPConfig()
    self.firewall = FirewallConfig()
    self.sqm = SQMConfig()

get_all_commands()

Get all UCI commands from all configuration sections.

Source code in src/wrtkit/config.py
def get_all_commands(self) -> List[UCICommand]:
    """Get all UCI commands from all configuration sections."""
    commands = []
    commands.extend(self.network.get_commands())
    commands.extend(self.wireless.get_commands())
    commands.extend(self.dhcp.get_commands())
    commands.extend(self.firewall.get_commands())
    commands.extend(self.sqm.get_commands())
    return commands

to_script(include_commit=True, include_reload=True)

Generate a shell script with all UCI commands.

Parameters:

Name Type Description Default
include_commit bool

Whether to include 'uci commit' command

True
include_reload bool

Whether to include network restart and wifi reload

True

Returns:

Type Description
str

A shell script as a string

Source code in src/wrtkit/config.py
def to_script(self, include_commit: bool = True, include_reload: bool = True) -> str:
    """
    Generate a shell script with all UCI commands.

    Args:
        include_commit: Whether to include 'uci commit' command
        include_reload: Whether to include network restart and wifi reload

    Returns:
        A shell script as a string
    """
    lines = ["#!/bin/sh", ""]

    commands = self.get_all_commands()

    # Delete existing anonymous sections before re-creating them
    anon_types = self._get_anonymous_section_types(commands)
    for package, section_type in anon_types:
        lines.append(f"while uci -q delete {package}.@{section_type}[-1]; do :; done")
    if anon_types:
        lines.append("")

    for cmd in commands:
        lines.append(cmd.to_string())

    if include_commit:
        lines.append("")
        lines.append("uci commit")

    if include_reload:
        lines.append("/etc/init.d/network restart")
        lines.append("wifi reload")
        lines.append("/etc/init.d/dnsmasq restart")

    return "\n".join(lines)

save_to_file(filename, include_commit=True, include_reload=True)

Save the configuration to a shell script file.

Parameters:

Name Type Description Default
filename str

Path to the output file

required
include_commit bool

Whether to include 'uci commit' command

True
include_reload bool

Whether to include network restart and wifi reload

True
Source code in src/wrtkit/config.py
def save_to_file(
    self, filename: str, include_commit: bool = True, include_reload: bool = True
) -> None:
    """
    Save the configuration to a shell script file.

    Args:
        filename: Path to the output file
        include_commit: Whether to include 'uci commit' command
        include_reload: Whether to include network restart and wifi reload
    """
    script = self.to_script(include_commit=include_commit, include_reload=include_reload)
    with open(filename, "w") as f:
        f.write(script)

diff(ssh, show_remote_only=True, remove_packages=None, verbose=False)

Compare this configuration with the remote device configuration.

Parameters:

Name Type Description Default
ssh SSHConnection

SSH connection to the remote device

required
show_remote_only bool

If True, track UCI settings on remote but not mentioned in local config. If False, all remote-only settings go to to_remove.

True
remove_packages Optional[List[str]]

Optional list of packages for which remote-only settings should be marked for removal (e.g., ["network", "wireless"]). When specified, only these packages' remote-only settings go to to_remove, others go to remote_only. This overrides show_remote_only for the specified packages.

None
verbose bool

If True, show progress spinner while fetching remote config

False

Returns:

Type Description
ConfigDiff

A ConfigDiff object describing the differences

Source code in src/wrtkit/config.py
def diff(
    self,
    ssh: SSHConnection,
    show_remote_only: bool = True,
    remove_packages: Optional[List[str]] = None,
    verbose: bool = False,
) -> ConfigDiff:
    """
    Compare this configuration with the remote device configuration.

    Args:
        ssh: SSH connection to the remote device
        show_remote_only: If True, track UCI settings on remote but not mentioned in local config.
                          If False, all remote-only settings go to to_remove.
        remove_packages: Optional list of packages for which remote-only settings should be
                         marked for removal (e.g., ["network", "wireless"]). When specified,
                         only these packages' remote-only settings go to to_remove, others
                         go to remote_only. This overrides show_remote_only for the
                         specified packages.
        verbose: If True, show progress spinner while fetching remote config

    Returns:
        A ConfigDiff object describing the differences
    """
    local_commands = self.get_all_commands()

    if verbose:
        spinner = Spinner("Fetching remote configuration...")
        spinner.start()
        try:
            remote_commands = self._parse_remote_config(ssh, spinner=spinner)
            spinner.stop("✓ Remote configuration fetched")
        except Exception:
            spinner.stop("✗ Failed to fetch remote configuration")
            raise
    else:
        remote_commands = self._parse_remote_config(ssh)

    diff = ConfigDiff()

    # Build section type mapping from commands (for logical path construction)
    section_types: Dict[str, Dict[str, str]] = {}  # {package: {section_name: section_type}}
    for cmd in local_commands + remote_commands:
        parts = cmd.path.split(".")
        if len(parts) == 2 and cmd.action == "set":
            # This is a section definition: package.section = type
            package = parts[0]
            section_name = parts[1]
            section_type = cmd.value if cmd.value else ""
            if package not in section_types:
                section_types[package] = {}
            section_types[package][section_name] = section_type

    # Build section-level tracking for tree display
    for cmd in local_commands:
        parts = cmd.path.split(".")
        if len(parts) >= 2:
            diff._local_sections.add((parts[0], parts[1]))

    for cmd in remote_commands:
        parts = cmd.path.split(".")
        if len(parts) >= 2:
            diff._remote_sections.add((parts[0], parts[1]))

    # Normalize format mismatches: when local uses 'set' with a space-joined
    # value (e.g. set firewall.wan.network='wan wan6') but remote has the same
    # data as 'add_list' entries, collapse remote add_list into a matching set.
    local_set_paths: dict[str, str] = {}
    for cmd in local_commands:
        if cmd.action == "set" and cmd.value and " " in cmd.value:
            local_set_paths[cmd.path] = cmd.value

    remote_add_list_groups: dict[str, list[str]] = {}
    for cmd in remote_commands:
        if cmd.action == "add_list" and cmd.path in local_set_paths:
            remote_add_list_groups.setdefault(cmd.path, []).append(cmd.value or "")

    if remote_add_list_groups:
        normalized_remote: list[UCICommand] = []
        for cmd in remote_commands:
            if cmd.action == "add_list" and cmd.path in remote_add_list_groups:
                continue  # skip individual add_list entries; replaced below
            normalized_remote.append(cmd)
        for path, values in remote_add_list_groups.items():
            normalized_remote.append(UCICommand("set", path, " ".join(values)))
        remote_commands = normalized_remote

    # Create sets for comparison
    # For add_list commands, we compare (path, value) pairs
    # For set commands, we track path -> value mapping
    local_set = {(cmd.path, cmd.value) for cmd in local_commands}
    remote_set = {(cmd.path, cmd.value) for cmd in remote_commands}

    local_paths = {c.path for c in local_commands}

    # Commands in local but not in remote
    for cmd in local_commands:
        key = (cmd.path, cmd.value)
        if key not in remote_set:
            # For add_list commands, if the (path, value) pair doesn't exist, it's an addition
            if cmd.action == "add_list":
                diff.to_add.append(cmd)
            else:
                # For set commands, check if path exists in remote with different value
                remote_paths = {c.path for c in remote_commands if c.action == "set"}
                if cmd.path in remote_paths:
                    # Find the remote command with same path
                    remote_cmd = next(
                        c for c in remote_commands if c.path == cmd.path and c.action == "set"
                    )
                    diff.to_modify.append((remote_cmd, cmd))
                else:
                    diff.to_add.append(cmd)
        else:
            # Setting exists in both with same value
            diff.common.append(cmd)

    # Commands in remote but not in local
    for cmd in remote_commands:
        key = (cmd.path, cmd.value)
        if key not in local_set:
            # Determine if this command should be marked for removal
            parts = cmd.path.split(".")
            cmd_package = parts[0]
            cmd_section = parts[1] if len(parts) >= 2 else ""
            should_remove = False

            # Check if this section exists in local config
            section_in_local = (cmd_package, cmd_section) in diff._local_sections
            section_is_remote_only = not section_in_local

            # Check if the package has a remote policy
            remote_policy = self.get_remote_policy(cmd_package)
            is_whitelisted = False

            if remote_policy is not None:
                # Remote policy controls which REMOTE-ONLY sections/values to keep
                if section_is_remote_only:
                    # Section only exists on remote - apply remote policy
                    # Use new whitelist approach if configured, otherwise fall back to legacy
                    if remote_policy.whitelist:
                        # New whitelist approach - construct logical path
                        pkg_section_types = section_types.get(cmd_package, {})
                        logical_path = self._get_logical_path(
                            cmd.path, pkg_section_types, cmd_package
                        )
                        if remote_policy.should_keep_remote_path(logical_path):
                            is_whitelisted = True
                        else:
                            should_remove = True
                    else:
                        # Legacy behavior for backward compatibility
                        if cmd.action == "add_list":
                            # For list values, check both section and value
                            if not remote_policy.should_keep_remote_value(
                                cmd_section, str(cmd.value)
                            ):
                                should_remove = True
                        else:
                            # For regular settings, check if section is allowed
                            if not remote_policy.should_keep_remote_section(cmd_section):
                                should_remove = True
                else:
                    # Section exists in both local and remote
                    # Remote options not in local should be removed
                    # UNLESS they are whitelisted
                    if remote_policy.whitelist:
                        # Check if this specific path is whitelisted
                        pkg_section_types = section_types.get(cmd_package, {})
                        logical_path = self._get_logical_path(
                            cmd.path, pkg_section_types, cmd_package
                        )
                        if remote_policy.should_keep_remote_path(logical_path):
                            is_whitelisted = True
                        else:
                            should_remove = True
                    else:
                        # Legacy behavior: always remove remote options in locally-managed sections
                        should_remove = True
            elif remove_packages is not None:
                # Per-package removal: only remove if package is in the list
                should_remove = cmd_package in remove_packages
            elif not show_remote_only:
                # Global removal: remove all remote-only
                should_remove = True

            # Categorize the command based on what should happen to it
            if cmd.action == "add_list":
                if should_remove:
                    diff.to_remove.append(cmd)
                elif is_whitelisted:
                    diff.whitelisted.append(cmd)
                else:
                    diff.remote_only.append(cmd)
            else:
                # For set commands, check if path exists in local
                if cmd.path not in local_paths:
                    # Path doesn't exist in local config at all
                    if should_remove:
                        diff.to_remove.append(cmd)
                    elif is_whitelisted:
                        diff.whitelisted.append(cmd)
                    else:
                        diff.remote_only.append(cmd)

    return diff

apply(ssh, dry_run=False, auto_commit=True, auto_reload=True, verbose=False)

Apply this configuration to a remote device.

Parameters:

Name Type Description Default
ssh SSHConnection

SSH connection to the remote device

required
dry_run bool

If True, only show what would be done

False
auto_commit bool

If True, automatically commit changes

True
auto_reload bool

If True, automatically reload network and wireless

True
verbose bool

If True, show progress spinner and status messages

False
Source code in src/wrtkit/config.py
def apply(
    self,
    ssh: SSHConnection,
    dry_run: bool = False,
    auto_commit: bool = True,
    auto_reload: bool = True,
    verbose: bool = False,
) -> None:
    """
    Apply this configuration to a remote device.

    Args:
        ssh: SSH connection to the remote device
        dry_run: If True, only show what would be done
        auto_commit: If True, automatically commit changes
        auto_reload: If True, automatically reload network and wireless
        verbose: If True, show progress spinner and status messages
    """
    commands = self.get_all_commands()

    # Determine which packages are being configured
    changed_packages: set[str] = set()
    for cmd in commands:
        parts = cmd.path.split(".")
        if parts:
            changed_packages.add(parts[0])

    # Identify anonymous section types that need cleanup before re-creation
    anon_types = self._get_anonymous_section_types(commands)

    if dry_run:
        print("[Dry run mode - no changes made]")
        if not commands:
            print("No commands to execute.")
            return
        for package, section_type in anon_types:
            print(f"Would run: while uci -q delete {package}.@{section_type}[-1]; do :; done")
        for cmd in commands:
            print(f"Would run: {cmd.to_string()}")
        if auto_commit and commands:
            print("Would run: uci commit")
        if auto_reload and commands:
            # Show only relevant restart commands based on packages being configured
            if "network" in changed_packages or "sqm" in changed_packages:
                print("Would run: /etc/init.d/network restart")
            if "wireless" in changed_packages:
                print("Would run: wifi reload")
            if "dhcp" in changed_packages:
                print("Would run: /etc/init.d/dnsmasq restart")
            if "firewall" in changed_packages:
                print("Would run: /etc/init.d/firewall reload")
        return

    # Calculate total steps (includes cleanup commands)
    total_steps = (
        len(anon_types) + len(commands) + (1 if auto_commit else 0) + (1 if auto_reload else 0)
    )

    if verbose and total_steps > 0:
        progress = ProgressBar(total_steps, "Applying configuration")
        progress._render()
    else:
        progress = None

    try:
        # Delete existing anonymous sections before re-creating them
        for package, section_type in anon_types:
            if progress:
                progress.update(message=f"Cleaning {package}.@{section_type}")
            cleanup_cmd = f"while uci -q delete {package}.@{section_type}[-1]; do :; done"
            ssh.execute_uci_command(cleanup_cmd)

        # Execute commands
        for cmd in commands:
            if progress:
                parts = cmd.path.split(".")
                if len(parts) >= 2:
                    progress.update(message=f"Applying {parts[0]}.{parts[1]}")
                else:
                    progress.update()

            stdout, stderr, exit_code = ssh.execute_uci_command(cmd.to_string())
            if exit_code != 0:
                if progress:
                    progress.finish(f"✗ Failed at command: {cmd.to_string()}")
                raise RuntimeError(f"Failed to execute command '{cmd.to_string()}': {stderr}")

        # Commit changes
        if auto_commit:
            if progress:
                progress.update(message="Committing changes")
            ssh.commit_changes()

        # Reload configuration (only services for changed packages)
        if auto_reload:
            if progress:
                progress.update(message="Reloading services")
            ssh.reload_config(changed_packages=changed_packages)

        if progress:
            progress.finish(f"✓ Applied {len(commands)} commands")

    except Exception:
        if progress:
            progress.finish("✗ Failed to apply configuration")
        raise

ConfigDiff Class

ConfigDiff

Represents the difference between two configurations.

Source code in src/wrtkit/config.py
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
class ConfigDiff:
    """Represents the difference between two configurations."""

    def __init__(self) -> None:
        self.to_add: List[UCICommand] = []
        self.to_remove: List[UCICommand] = []
        self.to_modify: List[tuple[UCICommand, UCICommand]] = []
        self.remote_only: List[
            UCICommand
        ] = []  # UCI settings on remote but not mentioned in config (NOT whitelisted, will be deleted)
        self.whitelisted: List[
            UCICommand
        ] = []  # UCI settings on remote that are whitelisted (preserved)
        self.common: List[UCICommand] = []  # UCI settings that match between local and remote
        # Section-level tracking for tree display
        self._local_sections: set[tuple[str, str]] = (
            set()
        )  # (package, section) pairs in local config
        self._remote_sections: set[tuple[str, str]] = set()  # (package, section) pairs on remote

    def is_empty(self) -> bool:
        """Check if there are no differences."""
        return not (self.to_add or self.to_remove or self.to_modify or self.remote_only)

    @staticmethod
    def _match_path_pattern(path: str, pattern: str) -> bool:
        """
        Match a UCI path against a glob pattern, supporting * and ** wildcards.

        Args:
            path: The UCI path to check (e.g., "network.interfaces.wan.ipaddr")
            pattern: The pattern to match against (e.g., "network.interfaces.*" or
                     "network.interfaces.wan.*")

        Returns:
            True if the path matches the pattern

        Examples:
            - "network.interfaces.*" matches "network.interfaces.wan" and
              "network.interfaces.wan.ipaddr"
            - "network.interfaces.wan.*" matches "network.interfaces.wan.ipaddr"
              but not "network.interfaces.lan.ipaddr"
            - "*.interfaces.*" matches any package's interfaces
        """
        # Handle exact wildcard match
        if pattern == "**":
            return True

        # Split path and pattern into segments
        path_parts = path.split(".")
        pattern_parts = pattern.split(".")

        # Track positions in both lists
        p_idx = 0  # path index
        pat_idx = 0  # pattern index

        while p_idx < len(path_parts) and pat_idx < len(pattern_parts):
            pattern_segment = pattern_parts[pat_idx]

            if pattern_segment == "**":
                # ** can match zero or more segments
                # Try to match the rest of the pattern with remaining path
                if pat_idx == len(pattern_parts) - 1:
                    # ** is at the end, matches everything remaining
                    return True

                # Try to match remaining pattern at each position in remaining path
                for i in range(p_idx, len(path_parts) + 1):
                    remaining_path = ".".join(path_parts[i:])
                    remaining_pattern = ".".join(pattern_parts[pat_idx + 1 :])
                    if ConfigDiff._match_path_pattern(remaining_path, remaining_pattern):
                        return True
                return False
            elif pattern_segment == "*":
                # * matches exactly one segment
                p_idx += 1
                pat_idx += 1
            elif fnmatch.fnmatch(path_parts[p_idx], pattern_segment):
                # Regular segment match with glob support
                p_idx += 1
                pat_idx += 1
            else:
                # No match
                return False

        # If pattern is exhausted but path has more parts, it's still a match
        # if the last pattern segment was * or ** (prefix match)
        if pat_idx == len(pattern_parts) and p_idx < len(path_parts):
            # Pattern ended but path continues - check if this is a prefix match
            # This allows "network.interfaces.*" to match "network.interfaces.wan.ipaddr"
            return True

        # Both must be exhausted for a full match
        return p_idx == len(path_parts) and pat_idx == len(pattern_parts)

    def filter_by_pattern(self, pattern: str) -> "ConfigDiff":
        """
        Create a new ConfigDiff containing only changes matching the given pattern.

        The pattern uses glob-style matching against UCI paths:
        - "*" matches exactly one path segment
        - "**" matches zero or more segments
        - Standard glob characters (?, [abc]) are also supported

        Args:
            pattern: Glob pattern to match against UCI paths
                     (e.g., "network.interfaces.*", "network.interfaces.wan.*")

        Returns:
            A new ConfigDiff containing only matching changes

        Examples:
            # Filter to only network interface changes
            filtered = diff.filter_by_pattern("network.interfaces.*")

            # Filter to only WAN interface changes
            filtered = diff.filter_by_pattern("network.interfaces.wan.*")

            # Filter to all wireless changes
            filtered = diff.filter_by_pattern("wireless.*")
        """
        filtered = ConfigDiff()

        # Filter to_add
        filtered.to_add = [
            cmd for cmd in self.to_add if self._match_path_pattern(cmd.path, pattern)
        ]

        # Filter to_remove
        filtered.to_remove = [
            cmd for cmd in self.to_remove if self._match_path_pattern(cmd.path, pattern)
        ]

        # Filter to_modify
        filtered.to_modify = [
            (old_cmd, new_cmd)
            for old_cmd, new_cmd in self.to_modify
            if self._match_path_pattern(new_cmd.path, pattern)
        ]

        # Filter remote_only
        filtered.remote_only = [
            cmd for cmd in self.remote_only if self._match_path_pattern(cmd.path, pattern)
        ]

        # Filter whitelisted
        filtered.whitelisted = [
            cmd for cmd in self.whitelisted if self._match_path_pattern(cmd.path, pattern)
        ]

        # Filter common
        filtered.common = [
            cmd for cmd in self.common if self._match_path_pattern(cmd.path, pattern)
        ]

        # Copy section tracking (filter based on pattern)
        for pkg, section in self._local_sections:
            section_path = f"{pkg}.{section}"
            if self._match_path_pattern(section_path, pattern):
                filtered._local_sections.add((pkg, section))

        for pkg, section in self._remote_sections:
            section_path = f"{pkg}.{section}"
            if self._match_path_pattern(section_path, pattern):
                filtered._remote_sections.add((pkg, section))

        return filtered

    def has_changes(self) -> bool:
        """Check if there are any changes to apply (excluding remote-only)."""
        return bool(self.to_add or self.to_remove or self.to_modify)

    def get_changed_packages(self) -> set[str]:
        """
        Get the set of packages that have changes.

        Returns:
            Set of package names (e.g., {"network", "wireless"}) that have changes.
        """
        packages: set[str] = set()

        for cmd in self.to_add:
            parts = cmd.path.split(".")
            if parts:
                packages.add(parts[0])

        for cmd in self.to_remove:
            parts = cmd.path.split(".")
            if parts:
                packages.add(parts[0])

        for old_cmd, new_cmd in self.to_modify:
            parts = new_cmd.path.split(".")
            if parts:
                packages.add(parts[0])

        return packages

    def get_removal_commands(self, packages: Optional[List[str]] = None) -> List[UCICommand]:
        """
        Get UCI delete commands for items that should be removed.

        This method is smart about deletions:
        - If an entire section is remote-only, it deletes just the section (not each option)
        - If only some options within a section need removal, it deletes those options
        - For list items, it uses del_list to remove specific values

        Args:
            packages: Optional list of packages to filter by (e.g., ["network", "wireless"]).
                      If None, returns removal commands for all packages.

        Returns:
            List of UCICommand with action='delete' for items to remove
        """
        removal_cmds = []
        deleted_sections: set[str] = set()  # Track sections we're already deleting

        # First pass: identify sections that are entirely remote-only
        # These are sections where the section itself is in to_remove
        for cmd in self.to_remove:
            parts = cmd.path.split(".")
            if len(parts) == 2:
                # This is a section definition (e.g., "wireless.mesh0_iface")
                pkg, section = parts[0], parts[1]
                if packages is None or pkg in packages:
                    if self.is_section_remote_only(pkg, section):
                        deleted_sections.add(cmd.path)

        # Second pass: generate removal commands
        for cmd in self.to_remove:
            # Filter by package if specified
            parts = cmd.path.split(".")
            if len(parts) < 2:
                continue

            cmd_package = parts[0]
            if packages is not None and cmd_package not in packages:
                continue

            # Check if this is a section definition or an option within a section
            if len(parts) == 2:
                # Section definition - delete the whole section
                removal_cmds.append(UCICommand("delete", cmd.path, None))
            else:
                # Option within a section (e.g., "wireless.mesh0_iface.device")
                section_path = f"{parts[0]}.{parts[1]}"

                # Skip if we're already deleting the entire section
                if section_path in deleted_sections:
                    continue

                if cmd.action == "add_list":
                    # For list items, use del_list
                    removal_cmds.append(UCICommand("del_list", cmd.path, cmd.value))
                else:
                    # For set commands, delete the option
                    removal_cmds.append(UCICommand("delete", cmd.path, None))

        # Sort so that anonymous section deletions happen in reverse index order.
        # Deleting @rule[0] shifts @rule[1] down to [0], so we must delete
        # higher indices first. Non-anonymous commands keep their relative order.
        def _anon_delete_sort_key(cmd: UCICommand) -> tuple[int, int]:
            parts = cmd.path.split(".")
            if cmd.action == "delete" and len(parts) == 2 and parts[1].startswith("@"):
                # Extract index from @type[N]
                bracket = parts[1].find("[")
                if bracket != -1:
                    try:
                        idx = int(parts[1][bracket + 1 : -1])
                        return (0, -idx)  # group 0, descending index
                    except ValueError:
                        pass
            return (1, 0)  # group 1: non-anonymous, preserve order

        removal_cmds.sort(key=_anon_delete_sort_key)

        return removal_cmds

    def is_section_config_only(self, package: str, section: str) -> bool:
        """Check if a section exists only in config (not on remote)."""
        key = (package, section)
        return key in self._local_sections and key not in self._remote_sections

    def is_section_remote_only(self, package: str, section: str) -> bool:
        """Check if a section exists only on remote (not in config)."""
        key = (package, section)
        return key not in self._local_sections and key in self._remote_sections

    def get_config_only_sections(self) -> List[tuple[str, str]]:
        """Get list of sections that exist only in config."""
        return sorted(self._local_sections - self._remote_sections)

    def get_remote_only_sections(self) -> List[tuple[str, str]]:
        """Get list of sections that exist only on remote."""
        return sorted(self._remote_sections - self._local_sections)

    def _group_commands_by_resource(
        self, commands: List[UCICommand]
    ) -> Dict[str, Dict[str, List[UCICommand]]]:
        """
        Group commands by package and section.

        Returns:
            Dict[package, Dict[section, List[commands]]]
        """
        grouped: Dict[str, Dict[str, List[UCICommand]]] = {}

        for cmd in commands:
            # Parse the path: package.section.option
            parts = cmd.path.split(".")
            if len(parts) < 2:
                continue

            package = parts[0]
            section = parts[1]

            if package not in grouped:
                grouped[package] = {}
            if section not in grouped[package]:
                grouped[package][section] = []

            grouped[package][section].append(cmd)

        return grouped

    def __str__(self) -> str:
        """Format the diff for display."""
        return self.to_string(color=True)

    def to_string(self, color: bool = False) -> str:
        """
        Format the diff for display.

        Args:
            color: If True, use ANSI color codes for terminal output

        Returns:
            Formatted diff string
        """
        if self.is_empty():
            return "No differences found."

        lines = []

        # Color prefixes
        if color:
            add_prefix = f"{Colors.GREEN}+{Colors.RESET}"
            remove_prefix = f"{Colors.RED}-{Colors.RESET}"
            modify_prefix = f"{Colors.YELLOW}~{Colors.RESET}"
            remote_prefix = f"{Colors.CYAN}*{Colors.RESET}"
            bold = f"{Colors.BOLD}"
            reset = f"{Colors.RESET}"
        else:
            add_prefix = "+"
            remove_prefix = "-"
            modify_prefix = "~"
            remote_prefix = "*"
            bold = ""
            reset = ""

        if self.to_add:
            lines.append("Commands to add:")
            for cmd in self.to_add:
                display_val = get_display_value(cmd.path, cmd.value)
                lines.append(f"  {add_prefix} {cmd.to_string_with_value(display_val)}")

        if self.to_remove:
            lines.append("\nCommands to remove:")
            for cmd in self.to_remove:
                display_val = get_display_value(cmd.path, cmd.value)
                lines.append(f"  {remove_prefix} {cmd.to_string_with_value(display_val)}")

        if self.to_modify:
            lines.append("\nCommands to modify:")
            for old_cmd, new_cmd in self.to_modify:
                old_display_val = get_display_value(old_cmd.path, old_cmd.value)
                new_display_val = get_display_value(new_cmd.path, new_cmd.value)
                lines.append(f"  {remove_prefix} {old_cmd.to_string_with_value(old_display_val)}")
                lines.append(f"  {add_prefix} {new_cmd.to_string_with_value(new_display_val)}")

        if self.remote_only:
            lines.append("\nRemote-only settings (not managed by config):")
            for cmd in self.remote_only:
                display_val = get_display_value(cmd.path, cmd.value)
                lines.append(f"  {remote_prefix} {cmd.to_string_with_value(display_val)}")

        # Summary footer
        summary_parts = []
        if self.to_add:
            summary_parts.append(f"{add_prefix}{len(self.to_add)} to add")
        if self.to_modify:
            summary_parts.append(f"{modify_prefix}{len(self.to_modify)} to modify")
        if self.to_remove:
            summary_parts.append(f"{remove_prefix}{len(self.to_remove)} to remove")
        if self.remote_only:
            summary_parts.append(f"{remote_prefix}{len(self.remote_only)} remote-only")
        if self.whitelisted:
            summary_parts.append(f"{len(self.whitelisted)} whitelisted")
        if self.common:
            summary_parts.append(f"{len(self.common)} in common")

        if summary_parts:
            lines.append("")
            lines.append(f"{bold}Summary:{reset} {', '.join(summary_parts)}")

        return "\n".join(lines)

    def to_tree(self, color: bool = True) -> str:
        """
        Format the diff as a hierarchical tree grouped by package and resource.

        Args:
            color: If True, use ANSI color codes for terminal output

        Returns:
            A tree-structured string representation of the diff
        """
        if self.is_empty():
            return "No differences found."

        lines = []

        # Color codes
        if color:
            add_sym = f"{Colors.GREEN}+{Colors.RESET}"
            remove_sym = f"{Colors.RED}-{Colors.RESET}"
            modify_sym = f"{Colors.YELLOW}~{Colors.RESET}"
            remote_sym = f"{Colors.CYAN}*{Colors.RESET}"
            pkg_color = f"{Colors.BOLD}"
            reset = f"{Colors.RESET}"
            remote_label = f"{Colors.DIM}(remote-only){Colors.RESET}"
            config_only_label = f"{Colors.GREEN}(config-only){Colors.RESET}"
            remote_only_section_label = f"{Colors.CYAN}(remote-only){Colors.RESET}"
        else:
            add_sym = "+"
            remove_sym = "-"
            modify_sym = "~"
            remote_sym = "*"
            pkg_color = ""
            reset = ""
            remote_label = "(remote-only)"
            config_only_label = "(config-only)"
            remote_only_section_label = "(remote-only)"

        # Group all changes by package and section
        add_grouped = self._group_commands_by_resource(self.to_add)
        remove_grouped = self._group_commands_by_resource(self.to_remove)
        remote_only_grouped = self._group_commands_by_resource(self.remote_only)
        # Don't group whitelisted items - they won't be displayed in the tree

        # Group modifications
        modify_grouped: Dict[str, Dict[str, List[tuple[UCICommand, UCICommand]]]] = {}
        for old_cmd, new_cmd in self.to_modify:
            parts = new_cmd.path.split(".")
            if len(parts) < 2:
                continue
            package = parts[0]
            section = parts[1]

            if package not in modify_grouped:
                modify_grouped[package] = {}
            if section not in modify_grouped[package]:
                modify_grouped[package][section] = []

            modify_grouped[package][section].append((old_cmd, new_cmd))

        # Get all packages involved
        all_packages: set[str] = set()
        all_packages.update(add_grouped.keys())
        all_packages.update(remove_grouped.keys())
        all_packages.update(modify_grouped.keys())
        all_packages.update(remote_only_grouped.keys())
        # Note: whitelisted items are not displayed, only counted

        # Format tree for each package
        for package in sorted(all_packages):
            lines.append(f"\n{pkg_color}{package}/{reset}")

            # Get all sections in this package
            sections: set[str] = set()
            if package in add_grouped:
                sections.update(add_grouped[package].keys())
            if package in remove_grouped:
                sections.update(remove_grouped[package].keys())
            if package in modify_grouped:
                sections.update(modify_grouped[package].keys())
            if package in remote_only_grouped:
                sections.update(remote_only_grouped[package].keys())

            sections_list = sorted(sections)
            for i, section in enumerate(sections_list):
                is_last_section = i == len(sections_list) - 1
                section_prefix = "└── " if is_last_section else "├── "
                item_prefix = "    " if is_last_section else "│   "

                # Determine section-level label
                section_label = ""
                if self.is_section_config_only(package, section):
                    section_label = f" {config_only_label}"
                elif self.is_section_remote_only(package, section):
                    section_label = f" {remote_only_section_label}"

                lines.append(f"{section_prefix}{section}{section_label}")

                # Add commands to add
                if package in add_grouped and section in add_grouped[package]:
                    for cmd in add_grouped[package][section]:
                        option = (
                            ".".join(cmd.path.split(".")[2:])
                            if len(cmd.path.split(".")) > 2
                            else cmd.path
                        )
                        display_val = get_display_value(cmd.path, cmd.value)
                        lines.append(f"{item_prefix}  {add_sym} {option} = {display_val}")

                # Add commands to remove
                if package in remove_grouped and section in remove_grouped[package]:
                    for cmd in remove_grouped[package][section]:
                        option = (
                            ".".join(cmd.path.split(".")[2:])
                            if len(cmd.path.split(".")) > 2
                            else cmd.path
                        )
                        display_val = get_display_value(cmd.path, cmd.value)
                        lines.append(f"{item_prefix}  {remove_sym} {option} = {display_val}")

                # Add commands to modify
                if package in modify_grouped and section in modify_grouped[package]:
                    for old_cmd, new_cmd in modify_grouped[package][section]:
                        option = (
                            ".".join(new_cmd.path.split(".")[2:])
                            if len(new_cmd.path.split(".")) > 2
                            else new_cmd.path
                        )
                        old_display_val = get_display_value(old_cmd.path, old_cmd.value)
                        new_display_val = get_display_value(new_cmd.path, new_cmd.value)
                        lines.append(f"{item_prefix}  {modify_sym} {option}")
                        lines.append(f"{item_prefix}    {remove_sym} {old_display_val}")
                        lines.append(f"{item_prefix}    {add_sym} {new_display_val}")

                # Add remote-only commands
                if package in remote_only_grouped and section in remote_only_grouped[package]:
                    for cmd in remote_only_grouped[package][section]:
                        option = (
                            ".".join(cmd.path.split(".")[2:])
                            if len(cmd.path.split(".")) > 2
                            else cmd.path
                        )
                        display_val = get_display_value(cmd.path, cmd.value)
                        lines.append(
                            f"{item_prefix}  {remote_sym} {option} = {display_val} {remote_label}"
                        )

        # Summary footer
        summary_parts = []
        if self.to_add:
            summary_parts.append(f"{add_sym}{len(self.to_add)} to add")
        if self.to_modify:
            summary_parts.append(f"{modify_sym}{len(self.to_modify)} to modify")
        if self.to_remove:
            summary_parts.append(f"{remove_sym}{len(self.to_remove)} to remove")
        if self.remote_only:
            summary_parts.append(f"{remote_sym}{len(self.remote_only)} remote-only")
        if self.whitelisted:
            summary_parts.append(f"{len(self.whitelisted)} whitelisted")
        if self.common:
            summary_parts.append(f"{len(self.common)} in common")

        if summary_parts:
            lines.append("")
            lines.append(f"{pkg_color}Summary:{reset} {', '.join(summary_parts)}")

        return "\n".join(lines)

is_empty()

Check if there are no differences.

Source code in src/wrtkit/config.py
def is_empty(self) -> bool:
    """Check if there are no differences."""
    return not (self.to_add or self.to_remove or self.to_modify or self.remote_only)

filter_by_pattern(pattern)

Create a new ConfigDiff containing only changes matching the given pattern.

The pattern uses glob-style matching against UCI paths: - "" matches exactly one path segment - "*" matches zero or more segments - Standard glob characters (?, [abc]) are also supported

Parameters:

Name Type Description Default
pattern str

Glob pattern to match against UCI paths (e.g., "network.interfaces.", "network.interfaces.wan.")

required

Returns:

Type Description
ConfigDiff

A new ConfigDiff containing only matching changes

Examples:

Filter to only network interface changes

filtered = diff.filter_by_pattern("network.interfaces.*")

Filter to only WAN interface changes

filtered = diff.filter_by_pattern("network.interfaces.wan.*")

Filter to all wireless changes

filtered = diff.filter_by_pattern("wireless.*")

Source code in src/wrtkit/config.py
def filter_by_pattern(self, pattern: str) -> "ConfigDiff":
    """
    Create a new ConfigDiff containing only changes matching the given pattern.

    The pattern uses glob-style matching against UCI paths:
    - "*" matches exactly one path segment
    - "**" matches zero or more segments
    - Standard glob characters (?, [abc]) are also supported

    Args:
        pattern: Glob pattern to match against UCI paths
                 (e.g., "network.interfaces.*", "network.interfaces.wan.*")

    Returns:
        A new ConfigDiff containing only matching changes

    Examples:
        # Filter to only network interface changes
        filtered = diff.filter_by_pattern("network.interfaces.*")

        # Filter to only WAN interface changes
        filtered = diff.filter_by_pattern("network.interfaces.wan.*")

        # Filter to all wireless changes
        filtered = diff.filter_by_pattern("wireless.*")
    """
    filtered = ConfigDiff()

    # Filter to_add
    filtered.to_add = [
        cmd for cmd in self.to_add if self._match_path_pattern(cmd.path, pattern)
    ]

    # Filter to_remove
    filtered.to_remove = [
        cmd for cmd in self.to_remove if self._match_path_pattern(cmd.path, pattern)
    ]

    # Filter to_modify
    filtered.to_modify = [
        (old_cmd, new_cmd)
        for old_cmd, new_cmd in self.to_modify
        if self._match_path_pattern(new_cmd.path, pattern)
    ]

    # Filter remote_only
    filtered.remote_only = [
        cmd for cmd in self.remote_only if self._match_path_pattern(cmd.path, pattern)
    ]

    # Filter whitelisted
    filtered.whitelisted = [
        cmd for cmd in self.whitelisted if self._match_path_pattern(cmd.path, pattern)
    ]

    # Filter common
    filtered.common = [
        cmd for cmd in self.common if self._match_path_pattern(cmd.path, pattern)
    ]

    # Copy section tracking (filter based on pattern)
    for pkg, section in self._local_sections:
        section_path = f"{pkg}.{section}"
        if self._match_path_pattern(section_path, pattern):
            filtered._local_sections.add((pkg, section))

    for pkg, section in self._remote_sections:
        section_path = f"{pkg}.{section}"
        if self._match_path_pattern(section_path, pattern):
            filtered._remote_sections.add((pkg, section))

    return filtered

has_changes()

Check if there are any changes to apply (excluding remote-only).

Source code in src/wrtkit/config.py
def has_changes(self) -> bool:
    """Check if there are any changes to apply (excluding remote-only)."""
    return bool(self.to_add or self.to_remove or self.to_modify)

get_changed_packages()

Get the set of packages that have changes.

Returns:

Type Description
set[str]

Set of package names (e.g., {"network", "wireless"}) that have changes.

Source code in src/wrtkit/config.py
def get_changed_packages(self) -> set[str]:
    """
    Get the set of packages that have changes.

    Returns:
        Set of package names (e.g., {"network", "wireless"}) that have changes.
    """
    packages: set[str] = set()

    for cmd in self.to_add:
        parts = cmd.path.split(".")
        if parts:
            packages.add(parts[0])

    for cmd in self.to_remove:
        parts = cmd.path.split(".")
        if parts:
            packages.add(parts[0])

    for old_cmd, new_cmd in self.to_modify:
        parts = new_cmd.path.split(".")
        if parts:
            packages.add(parts[0])

    return packages

get_removal_commands(packages=None)

Get UCI delete commands for items that should be removed.

This method is smart about deletions: - If an entire section is remote-only, it deletes just the section (not each option) - If only some options within a section need removal, it deletes those options - For list items, it uses del_list to remove specific values

Parameters:

Name Type Description Default
packages Optional[List[str]]

Optional list of packages to filter by (e.g., ["network", "wireless"]). If None, returns removal commands for all packages.

None

Returns:

Type Description
List[UCICommand]

List of UCICommand with action='delete' for items to remove

Source code in src/wrtkit/config.py
def get_removal_commands(self, packages: Optional[List[str]] = None) -> List[UCICommand]:
    """
    Get UCI delete commands for items that should be removed.

    This method is smart about deletions:
    - If an entire section is remote-only, it deletes just the section (not each option)
    - If only some options within a section need removal, it deletes those options
    - For list items, it uses del_list to remove specific values

    Args:
        packages: Optional list of packages to filter by (e.g., ["network", "wireless"]).
                  If None, returns removal commands for all packages.

    Returns:
        List of UCICommand with action='delete' for items to remove
    """
    removal_cmds = []
    deleted_sections: set[str] = set()  # Track sections we're already deleting

    # First pass: identify sections that are entirely remote-only
    # These are sections where the section itself is in to_remove
    for cmd in self.to_remove:
        parts = cmd.path.split(".")
        if len(parts) == 2:
            # This is a section definition (e.g., "wireless.mesh0_iface")
            pkg, section = parts[0], parts[1]
            if packages is None or pkg in packages:
                if self.is_section_remote_only(pkg, section):
                    deleted_sections.add(cmd.path)

    # Second pass: generate removal commands
    for cmd in self.to_remove:
        # Filter by package if specified
        parts = cmd.path.split(".")
        if len(parts) < 2:
            continue

        cmd_package = parts[0]
        if packages is not None and cmd_package not in packages:
            continue

        # Check if this is a section definition or an option within a section
        if len(parts) == 2:
            # Section definition - delete the whole section
            removal_cmds.append(UCICommand("delete", cmd.path, None))
        else:
            # Option within a section (e.g., "wireless.mesh0_iface.device")
            section_path = f"{parts[0]}.{parts[1]}"

            # Skip if we're already deleting the entire section
            if section_path in deleted_sections:
                continue

            if cmd.action == "add_list":
                # For list items, use del_list
                removal_cmds.append(UCICommand("del_list", cmd.path, cmd.value))
            else:
                # For set commands, delete the option
                removal_cmds.append(UCICommand("delete", cmd.path, None))

    # Sort so that anonymous section deletions happen in reverse index order.
    # Deleting @rule[0] shifts @rule[1] down to [0], so we must delete
    # higher indices first. Non-anonymous commands keep their relative order.
    def _anon_delete_sort_key(cmd: UCICommand) -> tuple[int, int]:
        parts = cmd.path.split(".")
        if cmd.action == "delete" and len(parts) == 2 and parts[1].startswith("@"):
            # Extract index from @type[N]
            bracket = parts[1].find("[")
            if bracket != -1:
                try:
                    idx = int(parts[1][bracket + 1 : -1])
                    return (0, -idx)  # group 0, descending index
                except ValueError:
                    pass
        return (1, 0)  # group 1: non-anonymous, preserve order

    removal_cmds.sort(key=_anon_delete_sort_key)

    return removal_cmds

is_section_config_only(package, section)

Check if a section exists only in config (not on remote).

Source code in src/wrtkit/config.py
def is_section_config_only(self, package: str, section: str) -> bool:
    """Check if a section exists only in config (not on remote)."""
    key = (package, section)
    return key in self._local_sections and key not in self._remote_sections

is_section_remote_only(package, section)

Check if a section exists only on remote (not in config).

Source code in src/wrtkit/config.py
def is_section_remote_only(self, package: str, section: str) -> bool:
    """Check if a section exists only on remote (not in config)."""
    key = (package, section)
    return key not in self._local_sections and key in self._remote_sections

get_config_only_sections()

Get list of sections that exist only in config.

Source code in src/wrtkit/config.py
def get_config_only_sections(self) -> List[tuple[str, str]]:
    """Get list of sections that exist only in config."""
    return sorted(self._local_sections - self._remote_sections)

get_remote_only_sections()

Get list of sections that exist only on remote.

Source code in src/wrtkit/config.py
def get_remote_only_sections(self) -> List[tuple[str, str]]:
    """Get list of sections that exist only on remote."""
    return sorted(self._remote_sections - self._local_sections)

__str__()

Format the diff for display.

Source code in src/wrtkit/config.py
def __str__(self) -> str:
    """Format the diff for display."""
    return self.to_string(color=True)

to_string(color=False)

Format the diff for display.

Parameters:

Name Type Description Default
color bool

If True, use ANSI color codes for terminal output

False

Returns:

Type Description
str

Formatted diff string

Source code in src/wrtkit/config.py
def to_string(self, color: bool = False) -> str:
    """
    Format the diff for display.

    Args:
        color: If True, use ANSI color codes for terminal output

    Returns:
        Formatted diff string
    """
    if self.is_empty():
        return "No differences found."

    lines = []

    # Color prefixes
    if color:
        add_prefix = f"{Colors.GREEN}+{Colors.RESET}"
        remove_prefix = f"{Colors.RED}-{Colors.RESET}"
        modify_prefix = f"{Colors.YELLOW}~{Colors.RESET}"
        remote_prefix = f"{Colors.CYAN}*{Colors.RESET}"
        bold = f"{Colors.BOLD}"
        reset = f"{Colors.RESET}"
    else:
        add_prefix = "+"
        remove_prefix = "-"
        modify_prefix = "~"
        remote_prefix = "*"
        bold = ""
        reset = ""

    if self.to_add:
        lines.append("Commands to add:")
        for cmd in self.to_add:
            display_val = get_display_value(cmd.path, cmd.value)
            lines.append(f"  {add_prefix} {cmd.to_string_with_value(display_val)}")

    if self.to_remove:
        lines.append("\nCommands to remove:")
        for cmd in self.to_remove:
            display_val = get_display_value(cmd.path, cmd.value)
            lines.append(f"  {remove_prefix} {cmd.to_string_with_value(display_val)}")

    if self.to_modify:
        lines.append("\nCommands to modify:")
        for old_cmd, new_cmd in self.to_modify:
            old_display_val = get_display_value(old_cmd.path, old_cmd.value)
            new_display_val = get_display_value(new_cmd.path, new_cmd.value)
            lines.append(f"  {remove_prefix} {old_cmd.to_string_with_value(old_display_val)}")
            lines.append(f"  {add_prefix} {new_cmd.to_string_with_value(new_display_val)}")

    if self.remote_only:
        lines.append("\nRemote-only settings (not managed by config):")
        for cmd in self.remote_only:
            display_val = get_display_value(cmd.path, cmd.value)
            lines.append(f"  {remote_prefix} {cmd.to_string_with_value(display_val)}")

    # Summary footer
    summary_parts = []
    if self.to_add:
        summary_parts.append(f"{add_prefix}{len(self.to_add)} to add")
    if self.to_modify:
        summary_parts.append(f"{modify_prefix}{len(self.to_modify)} to modify")
    if self.to_remove:
        summary_parts.append(f"{remove_prefix}{len(self.to_remove)} to remove")
    if self.remote_only:
        summary_parts.append(f"{remote_prefix}{len(self.remote_only)} remote-only")
    if self.whitelisted:
        summary_parts.append(f"{len(self.whitelisted)} whitelisted")
    if self.common:
        summary_parts.append(f"{len(self.common)} in common")

    if summary_parts:
        lines.append("")
        lines.append(f"{bold}Summary:{reset} {', '.join(summary_parts)}")

    return "\n".join(lines)

to_tree(color=True)

Format the diff as a hierarchical tree grouped by package and resource.

Parameters:

Name Type Description Default
color bool

If True, use ANSI color codes for terminal output

True

Returns:

Type Description
str

A tree-structured string representation of the diff

Source code in src/wrtkit/config.py
def to_tree(self, color: bool = True) -> str:
    """
    Format the diff as a hierarchical tree grouped by package and resource.

    Args:
        color: If True, use ANSI color codes for terminal output

    Returns:
        A tree-structured string representation of the diff
    """
    if self.is_empty():
        return "No differences found."

    lines = []

    # Color codes
    if color:
        add_sym = f"{Colors.GREEN}+{Colors.RESET}"
        remove_sym = f"{Colors.RED}-{Colors.RESET}"
        modify_sym = f"{Colors.YELLOW}~{Colors.RESET}"
        remote_sym = f"{Colors.CYAN}*{Colors.RESET}"
        pkg_color = f"{Colors.BOLD}"
        reset = f"{Colors.RESET}"
        remote_label = f"{Colors.DIM}(remote-only){Colors.RESET}"
        config_only_label = f"{Colors.GREEN}(config-only){Colors.RESET}"
        remote_only_section_label = f"{Colors.CYAN}(remote-only){Colors.RESET}"
    else:
        add_sym = "+"
        remove_sym = "-"
        modify_sym = "~"
        remote_sym = "*"
        pkg_color = ""
        reset = ""
        remote_label = "(remote-only)"
        config_only_label = "(config-only)"
        remote_only_section_label = "(remote-only)"

    # Group all changes by package and section
    add_grouped = self._group_commands_by_resource(self.to_add)
    remove_grouped = self._group_commands_by_resource(self.to_remove)
    remote_only_grouped = self._group_commands_by_resource(self.remote_only)
    # Don't group whitelisted items - they won't be displayed in the tree

    # Group modifications
    modify_grouped: Dict[str, Dict[str, List[tuple[UCICommand, UCICommand]]]] = {}
    for old_cmd, new_cmd in self.to_modify:
        parts = new_cmd.path.split(".")
        if len(parts) < 2:
            continue
        package = parts[0]
        section = parts[1]

        if package not in modify_grouped:
            modify_grouped[package] = {}
        if section not in modify_grouped[package]:
            modify_grouped[package][section] = []

        modify_grouped[package][section].append((old_cmd, new_cmd))

    # Get all packages involved
    all_packages: set[str] = set()
    all_packages.update(add_grouped.keys())
    all_packages.update(remove_grouped.keys())
    all_packages.update(modify_grouped.keys())
    all_packages.update(remote_only_grouped.keys())
    # Note: whitelisted items are not displayed, only counted

    # Format tree for each package
    for package in sorted(all_packages):
        lines.append(f"\n{pkg_color}{package}/{reset}")

        # Get all sections in this package
        sections: set[str] = set()
        if package in add_grouped:
            sections.update(add_grouped[package].keys())
        if package in remove_grouped:
            sections.update(remove_grouped[package].keys())
        if package in modify_grouped:
            sections.update(modify_grouped[package].keys())
        if package in remote_only_grouped:
            sections.update(remote_only_grouped[package].keys())

        sections_list = sorted(sections)
        for i, section in enumerate(sections_list):
            is_last_section = i == len(sections_list) - 1
            section_prefix = "└── " if is_last_section else "├── "
            item_prefix = "    " if is_last_section else "│   "

            # Determine section-level label
            section_label = ""
            if self.is_section_config_only(package, section):
                section_label = f" {config_only_label}"
            elif self.is_section_remote_only(package, section):
                section_label = f" {remote_only_section_label}"

            lines.append(f"{section_prefix}{section}{section_label}")

            # Add commands to add
            if package in add_grouped and section in add_grouped[package]:
                for cmd in add_grouped[package][section]:
                    option = (
                        ".".join(cmd.path.split(".")[2:])
                        if len(cmd.path.split(".")) > 2
                        else cmd.path
                    )
                    display_val = get_display_value(cmd.path, cmd.value)
                    lines.append(f"{item_prefix}  {add_sym} {option} = {display_val}")

            # Add commands to remove
            if package in remove_grouped and section in remove_grouped[package]:
                for cmd in remove_grouped[package][section]:
                    option = (
                        ".".join(cmd.path.split(".")[2:])
                        if len(cmd.path.split(".")) > 2
                        else cmd.path
                    )
                    display_val = get_display_value(cmd.path, cmd.value)
                    lines.append(f"{item_prefix}  {remove_sym} {option} = {display_val}")

            # Add commands to modify
            if package in modify_grouped and section in modify_grouped[package]:
                for old_cmd, new_cmd in modify_grouped[package][section]:
                    option = (
                        ".".join(new_cmd.path.split(".")[2:])
                        if len(new_cmd.path.split(".")) > 2
                        else new_cmd.path
                    )
                    old_display_val = get_display_value(old_cmd.path, old_cmd.value)
                    new_display_val = get_display_value(new_cmd.path, new_cmd.value)
                    lines.append(f"{item_prefix}  {modify_sym} {option}")
                    lines.append(f"{item_prefix}    {remove_sym} {old_display_val}")
                    lines.append(f"{item_prefix}    {add_sym} {new_display_val}")

            # Add remote-only commands
            if package in remote_only_grouped and section in remote_only_grouped[package]:
                for cmd in remote_only_grouped[package][section]:
                    option = (
                        ".".join(cmd.path.split(".")[2:])
                        if len(cmd.path.split(".")) > 2
                        else cmd.path
                    )
                    display_val = get_display_value(cmd.path, cmd.value)
                    lines.append(
                        f"{item_prefix}  {remote_sym} {option} = {display_val} {remote_label}"
                    )

    # Summary footer
    summary_parts = []
    if self.to_add:
        summary_parts.append(f"{add_sym}{len(self.to_add)} to add")
    if self.to_modify:
        summary_parts.append(f"{modify_sym}{len(self.to_modify)} to modify")
    if self.to_remove:
        summary_parts.append(f"{remove_sym}{len(self.to_remove)} to remove")
    if self.remote_only:
        summary_parts.append(f"{remote_sym}{len(self.remote_only)} remote-only")
    if self.whitelisted:
        summary_parts.append(f"{len(self.whitelisted)} whitelisted")
    if self.common:
        summary_parts.append(f"{len(self.common)} in common")

    if summary_parts:
        lines.append("")
        lines.append(f"{pkg_color}Summary:{reset} {', '.join(summary_parts)}")

    return "\n".join(lines)

Usage Example

from wrtkit import UCIConfig

config = UCIConfig()

# Access configuration managers
config.network    # NetworkConfig instance
config.wireless   # WirelessConfig instance
config.dhcp       # DHCPConfig instance
config.firewall   # FirewallConfig instance

# Generate script
script = config.to_script()

# Save to file
config.save_to_file("config.sh")

# Get all commands
commands = config.get_all_commands()

See Also