diff --git a/netbox/dcim/api/serializers.py b/netbox/dcim/api/serializers.py index d2a9125c0..4eeb717d7 100644 --- a/netbox/dcim/api/serializers.py +++ b/netbox/dcim/api/serializers.py @@ -651,10 +651,10 @@ class InterfaceSerializer(PrimaryModelSerializer, LinkTerminationSerializer, Con model = Interface fields = [ 'id', 'url', 'display', 'device', 'name', 'label', 'type', 'enabled', 'parent', 'lag', 'mtu', 'mac_address', - 'wwn', 'mgmt_only', 'description', 'mode', 'rf_role', 'rf_channel', 'rf_channel_width', 'untagged_vlan', - 'tagged_vlans', 'mark_connected', 'cable', 'link_peer', 'link_peer_type', 'connected_endpoint', - 'connected_endpoint_type', 'connected_endpoint_reachable', 'tags', 'custom_fields', 'created', - 'last_updated', 'count_ipaddresses', '_occupied', + 'wwn', 'mgmt_only', 'description', 'mode', 'rf_role', 'rf_channel', 'rf_channel_frequency', + 'rf_channel_width', 'untagged_vlan', 'tagged_vlans', 'mark_connected', 'cable', 'link_peer', + 'link_peer_type', 'connected_endpoint', 'connected_endpoint_type', 'connected_endpoint_reachable', 'tags', + 'custom_fields', 'created', 'last_updated', 'count_ipaddresses', '_occupied', ] def validate(self, data): diff --git a/netbox/dcim/forms/bulk_edit.py b/netbox/dcim/forms/bulk_edit.py index 9c6a85885..e8e60a4f9 100644 --- a/netbox/dcim/forms/bulk_edit.py +++ b/netbox/dcim/forms/bulk_edit.py @@ -936,7 +936,7 @@ class PowerOutletBulkEditForm( class InterfaceBulkEditForm( form_from_model(Interface, [ 'label', 'type', 'parent', 'lag', 'mac_address', 'wwn', 'mtu', 'mgmt_only', 'mark_connected', 'description', - 'mode', 'rf_role', 'rf_channel', 'rf_channel_width', + 'mode', 'rf_role', 'rf_channel', 'rf_channel_frequency', 'rf_channel_width', ]), BootstrapMixin, AddRemoveTagsForm, @@ -988,7 +988,7 @@ class InterfaceBulkEditForm( class Meta: nullable_fields = [ 'label', 'parent', 'lag', 'mac_address', 'wwn', 'mtu', 'description', 'mode', 'rf_channel', - 'rf_channel_width', 'untagged_vlan', 'tagged_vlans', + 'rf_channel_frequency', 'rf_channel_width', 'untagged_vlan', 'tagged_vlans', ] def __init__(self, *args, **kwargs): diff --git a/netbox/dcim/forms/bulk_import.py b/netbox/dcim/forms/bulk_import.py index 5ca009dee..4eb860836 100644 --- a/netbox/dcim/forms/bulk_import.py +++ b/netbox/dcim/forms/bulk_import.py @@ -595,7 +595,8 @@ class InterfaceCSVForm(CustomFieldModelCSVForm): model = Interface fields = ( 'device', 'name', 'label', 'parent', 'lag', 'type', 'enabled', 'mark_connected', 'mac_address', 'wwn', - 'mtu', 'mgmt_only', 'description', 'mode', 'rf_role', 'rf_channel', 'rf_channel_width', + 'mtu', 'mgmt_only', 'description', 'mode', 'rf_role', 'rf_channel', 'rf_channel_frequency', + 'rf_channel_width', ) def __init__(self, *args, **kwargs): diff --git a/netbox/dcim/forms/filtersets.py b/netbox/dcim/forms/filtersets.py index fb9449d41..e28714914 100644 --- a/netbox/dcim/forms/filtersets.py +++ b/netbox/dcim/forms/filtersets.py @@ -1014,9 +1014,13 @@ class InterfaceFilterForm(DeviceComponentFilterForm): widget=StaticSelectMultiple(), label='Wireless channel' ) + rf_channel_frequency = forms.IntegerField( + required=False, + label='Channel frequency (MHz)' + ) rf_channel_width = forms.IntegerField( required=False, - label='Channel width (kHz)' + label='Channel width (MHz)' ) tag = TagFilterField(model) diff --git a/netbox/dcim/forms/models.py b/netbox/dcim/forms/models.py index 675319f11..603767518 100644 --- a/netbox/dcim/forms/models.py +++ b/netbox/dcim/forms/models.py @@ -1108,8 +1108,8 @@ class InterfaceForm(BootstrapMixin, InterfaceCommonForm, CustomFieldModelForm): model = Interface fields = [ 'device', 'name', 'label', 'type', 'enabled', 'parent', 'lag', 'mac_address', 'wwn', 'mtu', 'mgmt_only', - 'mark_connected', 'description', 'mode', 'rf_role', 'rf_channel', 'rf_channel_width', 'wireless_lans', - 'untagged_vlan', 'tagged_vlans', 'tags', + 'mark_connected', 'description', 'mode', 'rf_role', 'rf_channel', 'rf_channel_frequency', + 'rf_channel_width', 'wireless_lans', 'untagged_vlan', 'tagged_vlans', 'tags', ] widgets = { 'device': forms.HiddenInput(), @@ -1123,6 +1123,8 @@ class InterfaceForm(BootstrapMixin, InterfaceCommonForm, CustomFieldModelForm): } help_texts = { 'mode': INTERFACE_MODE_HELP_TEXT, + 'rf_channel_frequency': "Populated by selected channel (if set)", + 'rf_channel_width': "Populated by selected channel (if set)", } def __init__(self, *args, **kwargs): diff --git a/netbox/dcim/forms/object_create.py b/netbox/dcim/forms/object_create.py index f924fb5d9..547fe7e68 100644 --- a/netbox/dcim/forms/object_create.py +++ b/netbox/dcim/forms/object_create.py @@ -480,9 +480,13 @@ class InterfaceCreateForm(ComponentCreateForm, InterfaceCommonForm): widget=StaticSelect(), label='Wireless channel' ) - rf_channel_width = forms.IntegerField( + rf_channel_frequency = forms.DecimalField( required=False, - label='Channel width' + label='Channel frequency (MHz)' + ) + rf_channel_width = forms.DecimalField( + required=False, + label='Channel width (MHz)' ) untagged_vlan = DynamicModelChoiceField( queryset=VLAN.objects.all(), @@ -494,8 +498,8 @@ class InterfaceCreateForm(ComponentCreateForm, InterfaceCommonForm): ) field_order = ( 'device', 'name_pattern', 'label_pattern', 'type', 'enabled', 'parent', 'lag', 'mtu', 'mac_address', - 'description', 'mgmt_only', 'mark_connected', 'rf_role', 'rf_channel', 'rf_channel_width', 'mode', - 'untagged_vlan', 'tagged_vlans', 'tags' + 'description', 'mgmt_only', 'mark_connected', 'rf_role', 'rf_channel', 'rf_channel_frequency', + 'rf_channel_width', 'mode', 'untagged_vlan', 'tagged_vlans', 'tags' ) def __init__(self, *args, **kwargs): diff --git a/netbox/dcim/migrations/0138_wireless.py b/netbox/dcim/migrations/0138_wireless.py index faebcf268..bbdb28283 100644 --- a/netbox/dcim/migrations/0138_wireless.py +++ b/netbox/dcim/migrations/0138_wireless.py @@ -20,10 +20,15 @@ class Migration(migrations.Migration): name='rf_channel', field=models.CharField(blank=True, max_length=50), ), + migrations.AddField( + model_name='interface', + name='rf_channel_frequency', + field=models.DecimalField(blank=True, decimal_places=2, max_digits=7, null=True), + ), migrations.AddField( model_name='interface', name='rf_channel_width', - field=models.PositiveSmallIntegerField(blank=True, null=True), + field=models.DecimalField(blank=True, decimal_places=3, max_digits=7, null=True), ), migrations.AddField( model_name='interface', diff --git a/netbox/dcim/models/device_components.py b/netbox/dcim/models/device_components.py index bb18e0c26..c2a37fcae 100644 --- a/netbox/dcim/models/device_components.py +++ b/netbox/dcim/models/device_components.py @@ -19,6 +19,7 @@ from utilities.ordering import naturalize_interface from utilities.querysets import RestrictedQuerySet from utilities.query_functions import CollateAsChar from wireless.choices import * +from wireless.utils import get_channel_attr __all__ = ( @@ -537,10 +538,19 @@ class Interface(ComponentModel, BaseInterface, LinkTermination, PathEndpoint): blank=True, verbose_name='Wireless channel' ) - rf_channel_width = models.PositiveSmallIntegerField( + rf_channel_frequency = models.DecimalField( + max_digits=7, + decimal_places=2, blank=True, null=True, - verbose_name='Channel width (kHz)' + verbose_name='Channel frequency (MHz)' + ) + rf_channel_width = models.DecimalField( + max_digits=7, + decimal_places=3, + blank=True, + null=True, + verbose_name='Channel width (MHz)' ) wireless_link = models.ForeignKey( to='wireless.WirelessLink', @@ -641,13 +651,33 @@ class Interface(ComponentModel, BaseInterface, LinkTermination, PathEndpoint): if self.pk and self.lag_id == self.pk: raise ValidationError({'lag': "A LAG interface cannot be its own parent."}) - # RF channel attributes may be set only for wireless interfaces + # RF role & channel may only be set for wireless interfaces if self.rf_role and not self.is_wireless: raise ValidationError({'rf_role': "Wireless role may be set only on wireless interfaces."}) if self.rf_channel and not self.is_wireless: raise ValidationError({'rf_channel': "Channel may be set only on wireless interfaces."}) - if self.rf_channel_width and not self.is_wireless: - raise ValidationError({'rf_channel_width': "Channel width may be set only on wireless interfaces."}) + + # Validate channel frequency against interface type and selected channel (if any) + if self.rf_channel_frequency: + if not self.is_wireless: + raise ValidationError({ + 'rf_channel_frequency': "Channel frequency may be set only on wireless interfaces.", + }) + if self.rf_channel and self.rf_channel_frequency != get_channel_attr(self.rf_channel, 'frequency'): + raise ValidationError({ + 'rf_channel_frequency': "Cannot specify custom frequency with channel selected.", + }) + elif self.rf_channel: + self.rf_channel_frequency = get_channel_attr(self.rf_channel, 'frequency') + + # Validate channel width against interface type and selected channel (if any) + if self.rf_channel_width: + if not self.is_wireless: + raise ValidationError({'rf_channel_width': "Channel width may be set only on wireless interfaces."}) + if self.rf_channel and self.rf_channel_width != get_channel_attr(self.rf_channel, 'width'): + raise ValidationError({'rf_channel_width': "Cannot specify custom width with channel selected."}) + elif self.rf_channel: + self.rf_channel_width = get_channel_attr(self.rf_channel, 'width') # Validate untagged VLAN if self.untagged_vlan and self.untagged_vlan.site not in [self.device.site, None]: diff --git a/netbox/dcim/tables/devices.py b/netbox/dcim/tables/devices.py index b0ef9807e..3b0ec349e 100644 --- a/netbox/dcim/tables/devices.py +++ b/netbox/dcim/tables/devices.py @@ -496,8 +496,9 @@ class InterfaceTable(DeviceComponentTable, BaseInterfaceTable, PathEndpointTable model = Interface fields = ( 'pk', 'name', 'device', 'label', 'enabled', 'type', 'mgmt_only', 'mtu', 'mode', 'mac_address', 'wwn', - 'rf_role', 'rf_channel', 'rf_channel_width', 'description', 'mark_connected', 'cable', 'cable_color', - 'wireless_link', 'link_peer', 'connection', 'tags', 'ip_addresses', 'untagged_vlan', 'tagged_vlans', + 'rf_role', 'rf_channel', 'rf_channel_frequency', 'rf_channel_width', 'description', 'mark_connected', + 'cable', 'cable_color', 'wireless_link', 'link_peer', 'connection', 'tags', 'ip_addresses', 'untagged_vlan', + 'tagged_vlans', ) default_columns = ('pk', 'name', 'device', 'label', 'enabled', 'type', 'description') diff --git a/netbox/templates/dcim/interface.html b/netbox/templates/dcim/interface.html index 427ea8352..6e01dee98 100644 --- a/netbox/templates/dcim/interface.html +++ b/netbox/templates/dcim/interface.html @@ -276,9 +276,25 @@ Channel {{ object.get_rf_channel_display|placeholder }} + + Channel Frequency + + {% if object.rf_channel_frequency %} + {{ object.rf_channel_frequency|simplify_decimal }} MHz + {% else %} + + {% endif %} + + Channel Width - {{ object.rf_channel_width|placeholder }} + + {% if object.rf_channel_width %} + {{ object.rf_channel_width|simplify_decimal }} MHz + {% else %} + + {% endif %} + diff --git a/netbox/templates/dcim/interface_edit.html b/netbox/templates/dcim/interface_edit.html index cb8d51828..de7d21269 100644 --- a/netbox/templates/dcim/interface_edit.html +++ b/netbox/templates/dcim/interface_edit.html @@ -36,6 +36,7 @@ {% render_field form.rf_role %} {% render_field form.rf_channel %} + {% render_field form.rf_channel_frequency %} {% render_field form.rf_channel_width %} {% render_field form.wireless_lans %} diff --git a/netbox/templates/wireless/inc/wirelesslink_interface.html b/netbox/templates/wireless/inc/wirelesslink_interface.html index 82f7cfd8d..e33047539 100644 --- a/netbox/templates/wireless/inc/wirelesslink_interface.html +++ b/netbox/templates/wireless/inc/wirelesslink_interface.html @@ -31,4 +31,24 @@ {{ interface.get_rf_channel_display|placeholder }} + + Channel Frequency + + {% if interface.rf_channel_frequency %} + {{ interface.rf_channel_frequency|simplify_decimal }} MHz + {% else %} + + {% endif %} + + + + Channel Width + + {% if interface.rf_channel_width %} + {{ interface.rf_channel_width|simplify_decimal }} MHz + {% else %} + + {% endif %} + + diff --git a/netbox/utilities/templatetags/helpers.py b/netbox/utilities/templatetags/helpers.py index a900d59e2..668596c8e 100644 --- a/netbox/utilities/templatetags/helpers.py +++ b/netbox/utilities/templatetags/helpers.py @@ -1,4 +1,5 @@ import datetime +import decimal import json import re from typing import Dict, Any @@ -146,6 +147,19 @@ def humanize_megabytes(mb): return f'{mb} MB' +@register.filter() +def simplify_decimal(value): + """ + Return the simplest expression of a decimal value. Examples: + 1.00 => '1' + 1.20 => '1.2' + 1.23 => '1.23' + """ + if type(value) is not decimal.Decimal: + return value + return str(value).rstrip('0.') + + @register.filter() def tzoffset(value): """ diff --git a/netbox/wireless/choices.py b/netbox/wireless/choices.py index 1369ee340..8a710b532 100644 --- a/netbox/wireless/choices.py +++ b/netbox/wireless/choices.py @@ -12,81 +12,79 @@ class WirelessRoleChoices(ChoiceSet): class WirelessChannelChoices(ChoiceSet): - CHANNEL_AUTO = 'auto' # 2.4 GHz - CHANNEL_24G_1 = '2.4g-1' - CHANNEL_24G_2 = '2.4g-2' - CHANNEL_24G_3 = '2.4g-3' - CHANNEL_24G_4 = '2.4g-4' - CHANNEL_24G_5 = '2.4g-5' - CHANNEL_24G_6 = '2.4g-6' - CHANNEL_24G_7 = '2.4g-7' - CHANNEL_24G_8 = '2.4g-8' - CHANNEL_24G_9 = '2.4g-9' - CHANNEL_24G_10 = '2.4g-10' - CHANNEL_24G_11 = '2.4g-11' - CHANNEL_24G_12 = '2.4g-12' - CHANNEL_24G_13 = '2.4g-13' + CHANNEL_24G_1 = '2.4g-1-2412-22' + CHANNEL_24G_2 = '2.4g-2-2417-22' + CHANNEL_24G_3 = '2.4g-3-2422-22' + CHANNEL_24G_4 = '2.4g-4-2427-22' + CHANNEL_24G_5 = '2.4g-5-2432-22' + CHANNEL_24G_6 = '2.4g-6-2437-22' + CHANNEL_24G_7 = '2.4g-7-2442-22' + CHANNEL_24G_8 = '2.4g-8-2447-22' + CHANNEL_24G_9 = '2.4g-9-2452-22' + CHANNEL_24G_10 = '2.4g-10-2457-22' + CHANNEL_24G_11 = '2.4g-11-2462-22' + CHANNEL_24G_12 = '2.4g-12-2467-22' + CHANNEL_24G_13 = '2.4g-13-2472-22' # 5 GHz - CHANNEL_5G_32 = '5g-32' - CHANNEL_5G_34 = '5g-34' - CHANNEL_5G_36 = '5g-36' - CHANNEL_5G_38 = '5g-38' - CHANNEL_5G_40 = '5g-40' - CHANNEL_5G_42 = '5g-42' - CHANNEL_5G_44 = '5g-44' - CHANNEL_5G_46 = '5g-46' - CHANNEL_5G_48 = '5g-48' - CHANNEL_5G_50 = '5g-50' - CHANNEL_5G_52 = '5g-52' - CHANNEL_5G_54 = '5g-54' - CHANNEL_5G_56 = '5g-56' - CHANNEL_5G_58 = '5g-58' - CHANNEL_5G_60 = '5g-60' - CHANNEL_5G_62 = '5g-62' - CHANNEL_5G_64 = '5g-64' - CHANNEL_5G_100 = '5g-100' - CHANNEL_5G_102 = '5g-102' - CHANNEL_5G_104 = '5g-104' - CHANNEL_5G_106 = '5g-106' - CHANNEL_5G_108 = '5g-108' - CHANNEL_5G_110 = '5g-110' - CHANNEL_5G_112 = '5g-112' - CHANNEL_5G_114 = '5g-114' - CHANNEL_5G_116 = '5g-116' - CHANNEL_5G_118 = '5g-118' - CHANNEL_5G_120 = '5g-120' - CHANNEL_5G_122 = '5g-122' - CHANNEL_5G_124 = '5g-124' - CHANNEL_5G_126 = '5g-126' - CHANNEL_5G_128 = '5g-128' - CHANNEL_5G_132 = '5g-132' - CHANNEL_5G_134 = '5g-134' - CHANNEL_5G_136 = '5g-136' - CHANNEL_5G_138 = '5g-138' - CHANNEL_5G_140 = '5g-140' - CHANNEL_5G_142 = '5g-142' - CHANNEL_5G_144 = '5g-144' - CHANNEL_5G_149 = '5g-149' - CHANNEL_5G_151 = '5g-151' - CHANNEL_5G_153 = '5g-153' - CHANNEL_5G_155 = '5g-155' - CHANNEL_5G_157 = '5g-157' - CHANNEL_5G_159 = '5g-159' - CHANNEL_5G_161 = '5g-161' - CHANNEL_5G_163 = '5g-163' - CHANNEL_5G_165 = '5g-165' - CHANNEL_5G_167 = '5g-167' - CHANNEL_5G_169 = '5g-169' - CHANNEL_5G_171 = '5g-171' - CHANNEL_5G_173 = '5g-173' - CHANNEL_5G_175 = '5g-175' - CHANNEL_5G_177 = '5g-177' + CHANNEL_5G_32 = '5g-32-5160-20' + CHANNEL_5G_34 = '5g-34-5170-40' + CHANNEL_5G_36 = '5g-36-5180-20' + CHANNEL_5G_38 = '5g-38-5190-40' + CHANNEL_5G_40 = '5g-40-5200-20' + CHANNEL_5G_42 = '5g-42-5210-80' + CHANNEL_5G_44 = '5g-44-5220-20' + CHANNEL_5G_46 = '5g-46-5230-40' + CHANNEL_5G_48 = '5g-48-5240-20' + CHANNEL_5G_50 = '5g-50-5250-160' + CHANNEL_5G_52 = '5g-52-5260-20' + CHANNEL_5G_54 = '5g-54-5270-40' + CHANNEL_5G_56 = '5g-56-5280-20' + CHANNEL_5G_58 = '5g-58-5290-80' + CHANNEL_5G_60 = '5g-60-5300-20' + CHANNEL_5G_62 = '5g-62-5310-40' + CHANNEL_5G_64 = '5g-64-5320-20' + CHANNEL_5G_100 = '5g-100-5500-20' + CHANNEL_5G_102 = '5g-102-5510-40' + CHANNEL_5G_104 = '5g-104-5520-20' + CHANNEL_5G_106 = '5g-106-5530-80' + CHANNEL_5G_108 = '5g-108-5540-20' + CHANNEL_5G_110 = '5g-110-5550-40' + CHANNEL_5G_112 = '5g-112-5560-20' + CHANNEL_5G_114 = '5g-114-5570-160' + CHANNEL_5G_116 = '5g-116-5580-20' + CHANNEL_5G_118 = '5g-118-5590-40' + CHANNEL_5G_120 = '5g-120-5600-20' + CHANNEL_5G_122 = '5g-122-5610-80' + CHANNEL_5G_124 = '5g-124-5620-20' + CHANNEL_5G_126 = '5g-126-5630-40' + CHANNEL_5G_128 = '5g-128-5640-20' + CHANNEL_5G_132 = '5g-132-5660-20' + CHANNEL_5G_134 = '5g-134-5670-40' + CHANNEL_5G_136 = '5g-136-5680-20' + CHANNEL_5G_138 = '5g-138-5690-80' + CHANNEL_5G_140 = '5g-140-5700-20' + CHANNEL_5G_142 = '5g-142-5710-40' + CHANNEL_5G_144 = '5g-144-5720-20' + CHANNEL_5G_149 = '5g-149-5745-20' + CHANNEL_5G_151 = '5g-151-5755-40' + CHANNEL_5G_153 = '5g-153-5765-20' + CHANNEL_5G_155 = '5g-155-5775-80' + CHANNEL_5G_157 = '5g-157-5785-20' + CHANNEL_5G_159 = '5g-159-5795-40' + CHANNEL_5G_161 = '5g-161-5805-20' + CHANNEL_5G_163 = '5g-163-5815-160' + CHANNEL_5G_165 = '5g-165-5825-20' + CHANNEL_5G_167 = '5g-167-5835-40' + CHANNEL_5G_169 = '5g-169-5845-20' + CHANNEL_5G_171 = '5g-171-5855-80' + CHANNEL_5G_173 = '5g-173-5865-20' + CHANNEL_5G_175 = '5g-175-5875-40' + CHANNEL_5G_177 = '5g-177-5885-20' CHOICES = ( - (CHANNEL_AUTO, 'Auto'), ( '2.4 GHz (802.11b/g/n/ax)', ( diff --git a/netbox/wireless/forms/models.py b/netbox/wireless/forms/models.py index a3454c79a..9a7b78b31 100644 --- a/netbox/wireless/forms/models.py +++ b/netbox/wireless/forms/models.py @@ -56,7 +56,10 @@ class WirelessLANForm(BootstrapMixin, CustomFieldModelForm): class WirelessLinkForm(BootstrapMixin, CustomFieldModelForm): device_a = DynamicModelChoiceField( queryset=Device.objects.all(), - label='Device A' + label='Device A', + initial_params={ + 'interfaces': '$interface_a' + } ) interface_a = DynamicModelChoiceField( queryset=Interface.objects.all(), @@ -69,7 +72,10 @@ class WirelessLinkForm(BootstrapMixin, CustomFieldModelForm): ) device_b = DynamicModelChoiceField( queryset=Device.objects.all(), - label='Device B' + label='Device B', + initial_params={ + 'interfaces': '$interface_b' + } ) interface_b = DynamicModelChoiceField( queryset=Interface.objects.all(), diff --git a/netbox/wireless/utils.py b/netbox/wireless/utils.py new file mode 100644 index 000000000..d98d6a853 --- /dev/null +++ b/netbox/wireless/utils.py @@ -0,0 +1,27 @@ +from decimal import Decimal + +from .choices import WirelessChannelChoices + +__all__ = ( + 'get_channel_attr', +) + + +def get_channel_attr(channel, attr): + """ + Return the specified attribute of a given WirelessChannelChoices value. + """ + if channel not in WirelessChannelChoices.values(): + raise ValueError(f"Invalid channel value: {channel}") + + channel_values = channel.split('-') + attrs = { + 'band': channel_values[0], + 'id': int(channel_values[1]), + 'frequency': Decimal(channel_values[2]), + 'width': Decimal(channel_values[3]), + } + if attr not in attrs: + raise ValueError(f"Invalid channel attribute: {attr}") + + return attrs[attr]