[6320] | 1 | # -*- coding: utf-8 -*- |
---|
| 2 | """ |
---|
| 3 | ------------------------------------------------------------------------------ |
---|
| 4 | Mango 802.11 Reference Design Experiments Framework - Access Point Node |
---|
| 5 | ------------------------------------------------------------------------------ |
---|
| 6 | License: Copyright 2019 Mango Communications, Inc. All rights reserved. |
---|
| 7 | Use and distribution subject to terms in LICENSE.txt |
---|
| 8 | ------------------------------------------------------------------------------ |
---|
| 9 | |
---|
| 10 | """ |
---|
| 11 | |
---|
| 12 | import wlan_exp.node as node |
---|
| 13 | import wlan_exp.cmds as cmds |
---|
| 14 | |
---|
| 15 | |
---|
| 16 | __all__ = ['WlanExpNodeAp'] |
---|
| 17 | |
---|
| 18 | |
---|
| 19 | class WlanExpNodeAp(node.WlanExpNode): |
---|
| 20 | """wlan_exp Node class for the 802.11 Reference Design AP MAC project |
---|
| 21 | |
---|
| 22 | Args: |
---|
| 23 | network_config (transport.NetworkConfiguration) : Network configuration of the node |
---|
| 24 | """ |
---|
| 25 | |
---|
| 26 | #------------------------------------------------------------------------- |
---|
| 27 | # Node Commands |
---|
| 28 | #------------------------------------------------------------------------- |
---|
| 29 | def configure_bss(self, bssid=False, ssid=None, channel=None, beacon_interval=False, dtim_period=None, ht_capable=None): |
---|
| 30 | """Configure the BSS information of the node |
---|
| 31 | |
---|
| 32 | Each node is either a member of no BSS (colloquially "unassociated") |
---|
| 33 | or a member of one BSS. A node requires a minimum valid set of BSS |
---|
| 34 | information to be a member of a BSS. The minimum valid set of BSS |
---|
| 35 | information for an AP is: |
---|
| 36 | |
---|
| 37 | #. BSSID: 48-bit MAC address |
---|
| 38 | #. Channel: Logical channel for Tx/Rx by BSS members |
---|
| 39 | #. SSID: Variable length string (ie the name of the network) |
---|
| 40 | #. Beacon Interval: Interval (in TUs) for beacons |
---|
| 41 | |
---|
| 42 | If a node is not a member of a BSS (i.e. ``n.get_network_info()`` returns |
---|
| 43 | ``None``), then the node requires all parameters of a minimum valid |
---|
| 44 | set of BSS information be specified (i.e. Channel, SSID, and |
---|
| 45 | Beacon Interval). For an AP, if BSSID is not specified, then it is |
---|
| 46 | assumed to be the wlan_mac_address of the node. |
---|
| 47 | |
---|
| 48 | See https://warpproject.org/trac/wiki/802.11/wlan_exp/bss |
---|
| 49 | for more documentation on BSS information / configuration. |
---|
| 50 | |
---|
| 51 | Args: |
---|
| 52 | bssid (int, str): 48-bit ID of the BSS either None or |
---|
| 53 | the wlan_mac_address of the node. If not specified, it is |
---|
| 54 | by default the wlan_mac_address of the node. |
---|
| 55 | ssid (str): SSID string (Must be 32 characters or less) |
---|
| 56 | channel (int): Channel number on which the BSS operates |
---|
| 57 | beacon_interval (int): Integer number of beacon Time Units in [10, 65534] |
---|
| 58 | (http://en.wikipedia.org/wiki/TU_(Time_Unit); a TU is 1024 microseconds); |
---|
| 59 | A value of None will disable beacons; A value of False will not |
---|
| 60 | update the current beacon interval. |
---|
| 61 | dtim_period (int): Integer number of beacon intervals between DTIMs |
---|
| 62 | ht_capable (bool): Is the PHY mode HTMF (True) or NONHT (False)? |
---|
| 63 | |
---|
| 64 | """ |
---|
| 65 | if (self.is_scanning() is True) and (bssid is None): |
---|
| 66 | print('Warning: network scan is still running when BSS was set to None. Use stop_active_scan if needed.') |
---|
| 67 | |
---|
| 68 | if bssid is not None: |
---|
| 69 | # Remember the bssid argument value, used in error checking the response below |
---|
| 70 | self.bssid = bssid |
---|
| 71 | if bssid is not False: |
---|
| 72 | # User supplied a not-None BSSID argument |
---|
| 73 | error = False |
---|
| 74 | |
---|
| 75 | if type(bssid) in [int, long]: |
---|
| 76 | if (bssid != self.wlan_mac_address): |
---|
| 77 | error = True |
---|
| 78 | elif type(bssid) is str: |
---|
| 79 | import wlan_exp.util as util |
---|
| 80 | try: |
---|
| 81 | if (util.str_to_mac_addr(bssid) != self.wlan_mac_address): |
---|
| 82 | error = True |
---|
| 83 | except: |
---|
| 84 | error = True |
---|
| 85 | |
---|
| 86 | if (error): |
---|
| 87 | raise AttributeError("BSSID must be either None or the wlan_mac_address of the node.") |
---|
| 88 | else: |
---|
| 89 | # User did not provide a BSSID argument - use the AP's MAC address |
---|
| 90 | bssid = self.wlan_mac_address |
---|
| 91 | resp_args = self.send_cmd(cmds.NodeConfigBSS(bssid=bssid, ssid=ssid, channel=channel, |
---|
| 92 | beacon_interval=beacon_interval, dtim_period=dtim_period, ht_capable=ht_capable)) |
---|
| 93 | |
---|
| 94 | # Process response arguments |
---|
| 95 | if (resp_args is not False): |
---|
| 96 | status = resp_args[0] |
---|
| 97 | msg = "ERROR:\n" |
---|
| 98 | ret_val = True |
---|
| 99 | |
---|
| 100 | # Check status |
---|
| 101 | if (status & cmds.ERROR_CONFIG_BSS_BSSID_INVALID): |
---|
| 102 | if type(self.bssid) in [int, long]: |
---|
| 103 | import wlan_exp.util as util |
---|
| 104 | self.bssid = util.mac_addr_to_str(self.bssid) |
---|
| 105 | msg += " BSSID {0} was invalid.\n".format(self.bssid) |
---|
| 106 | ret_val = False |
---|
| 107 | |
---|
| 108 | if (status & cmds.ERROR_CONFIG_BSS_BSSID_INSUFFICIENT_ARGUMENTS): |
---|
| 109 | msg += " Insufficient arguments to create BSS. Must provide:\n" |
---|
| 110 | if (bssid is False): |
---|
| 111 | msg += " BSSID\n" |
---|
| 112 | if (ssid is None): |
---|
| 113 | msg += " SSID\n" |
---|
| 114 | if (channel is None): |
---|
| 115 | msg += " CHANNEL\n" |
---|
| 116 | if (beacon_interval is False): |
---|
| 117 | msg += " BEACON_INTERVAL\n" |
---|
| 118 | ret_val = False |
---|
| 119 | |
---|
| 120 | if (status & cmds.ERROR_CONFIG_BSS_CHANNEL_INVALID): |
---|
| 121 | msg += " Channel {0} was invalid.\n".format(channel) |
---|
| 122 | ret_val = False |
---|
| 123 | |
---|
| 124 | if (status & cmds.ERROR_CONFIG_BSS_BEACON_INTERVAL_INVALID): |
---|
| 125 | msg += " Beacon interval {0} was invalid.\n".format(beacon_interval) |
---|
| 126 | ret_val = False |
---|
| 127 | |
---|
| 128 | if (status & cmds.ERROR_CONFIG_BSS_HT_CAPABLE_INVALID): |
---|
| 129 | msg += " HT capable {0} was invalid.\n".format(ht_capable) |
---|
| 130 | ret_val = False |
---|
| 131 | |
---|
| 132 | if (status & cmds.ERROR_CONFIG_BSS_DTIM_PERIOD_INVALID): |
---|
| 133 | msg += " DTIM period {0} was invalid.\n".format(dtim_period) |
---|
| 134 | ret_val = False |
---|
| 135 | |
---|
| 136 | if not ret_val: |
---|
| 137 | print(msg) |
---|
| 138 | |
---|
| 139 | |
---|
| 140 | def enable_beacon_mac_time_update(self, enable): |
---|
| 141 | """Enable / Disable MAC time update from beacons |
---|
| 142 | |
---|
| 143 | Raises NotImplementedError(). Current AP implementation does not |
---|
| 144 | support updating MAC time from beacon receptions |
---|
| 145 | |
---|
| 146 | Args: |
---|
| 147 | enable (bool): True - enable MAC time updates from beacons |
---|
| 148 | False - disable MAC time updates from beacons |
---|
| 149 | |
---|
| 150 | """ |
---|
| 151 | raise NotImplementedError("Current AP implementation does not support updating MAC time from beacon receptions") |
---|
| 152 | |
---|
| 153 | |
---|
| 154 | def is_associated(self, device_list): |
---|
| 155 | """Are the devices in the device_list in the AP association table? |
---|
| 156 | |
---|
| 157 | Args: |
---|
| 158 | device_list (WlanDevice): List of WLAN device (or sub-class of |
---|
| 159 | WLAN device) |
---|
| 160 | |
---|
| 161 | Returns: |
---|
| 162 | associated (list of bool): List of booleans describing whether each given device is associated with the AP |
---|
| 163 | |
---|
| 164 | If the device_list is a single device, then only a boolean is returned. |
---|
| 165 | If the device_list is a list of devices, then a list of booleans will |
---|
| 166 | be returned in the same order as the devices in the list. |
---|
| 167 | """ |
---|
| 168 | ret_val = [] |
---|
| 169 | ret_list = True |
---|
| 170 | is_member = False |
---|
| 171 | |
---|
| 172 | if device_list is not None: |
---|
| 173 | # Convert device_list to a list if it is not already one; set flag to not return a list |
---|
| 174 | if type(device_list) is not list: |
---|
| 175 | device_list = [device_list] |
---|
| 176 | ret_list = False |
---|
| 177 | |
---|
| 178 | for device in device_list: |
---|
| 179 | # Check the station info to see if device is there |
---|
| 180 | |
---|
| 181 | bss_member_list = self.get_bss_members() |
---|
| 182 | |
---|
| 183 | for bss_member in bss_member_list: |
---|
| 184 | if bss_member['mac_addr'] == device.wlan_mac_address: |
---|
| 185 | is_member = True |
---|
| 186 | |
---|
| 187 | if is_member is True: |
---|
| 188 | ret_val.append(True) |
---|
| 189 | else: |
---|
| 190 | ret_val.append(False) |
---|
| 191 | else: |
---|
| 192 | ret_val = False |
---|
| 193 | |
---|
| 194 | # Need to return a single value and not a list |
---|
| 195 | if not ret_list: |
---|
| 196 | ret_val = ret_val[0] |
---|
| 197 | |
---|
| 198 | return ret_val |
---|
| 199 | |
---|
| 200 | |
---|
| 201 | |
---|
| 202 | #------------------------------------------------------------------------- |
---|
| 203 | # Internal Node methods |
---|
| 204 | #------------------------------------------------------------------------- |
---|
| 205 | def _check_allowed_rate(self, mcs, phy_mode, verbose=False): |
---|
| 206 | """Check that rate parameters are allowed |
---|
| 207 | |
---|
| 208 | Args: |
---|
| 209 | mcs (int): Modulation and coding scheme (MCS) index |
---|
| 210 | phy_mode (str, int): PHY mode (from util.phy_modes) |
---|
| 211 | |
---|
| 212 | Returns: |
---|
| 213 | valid (bool): Are all parameters valid? |
---|
| 214 | """ |
---|
| 215 | |
---|
| 216 | # TODO: implement AP-specific rate checking here |
---|
| 217 | # Allow all supported rates for now |
---|
| 218 | |
---|
| 219 | return self._check_supported_rate(mcs, phy_mode, verbose) |
---|
| 220 | |
---|
| 221 | |
---|
| 222 | |
---|
| 223 | #------------------------------------------------------------------------- |
---|
| 224 | # AP specific commands |
---|
| 225 | #------------------------------------------------------------------------- |
---|
| 226 | def disassociate(self, device_list): |
---|
| 227 | """De-authenticate specific devices and remove the devices from the AP's |
---|
| 228 | association tables. This method triggers transmission of a de-authenticaion |
---|
| 229 | packet to the targeted STA nodes. The STA nodes are then removed from the AP |
---|
| 230 | association table. |
---|
| 231 | |
---|
| 232 | Args: |
---|
| 233 | device_list (list of WlanExpNode / WlanDevice): List of 802.11 |
---|
| 234 | devices or single 802.11 device for which to disassociate |
---|
| 235 | """ |
---|
| 236 | try: |
---|
| 237 | for device in device_list: |
---|
| 238 | self.send_cmd(cmds.NodeDisassociate(device)) |
---|
| 239 | except TypeError: |
---|
| 240 | self.send_cmd(cmds.NodeDisassociate(device_list)) |
---|
| 241 | |
---|
| 242 | |
---|
| 243 | def disassociate_all(self): |
---|
| 244 | """De-authenticates all devices and removes all devices from the AP's |
---|
| 245 | association tables. This method triggers transmission of a de-authenticaion |
---|
| 246 | packet to every associated STA node. The STA nodes are then removed from the AP |
---|
| 247 | association table. |
---|
| 248 | |
---|
| 249 | """ |
---|
| 250 | self.send_cmd(cmds.NodeDisassociate()) |
---|
| 251 | |
---|
| 252 | |
---|
| 253 | def enable_DTIM_multicast_buffering(self, enable): |
---|
| 254 | """Enable / Disable DTIM buffering of multicast data |
---|
| 255 | |
---|
| 256 | The Delivery Traffic Indication Map (DTIM) keeps track of STA sleep |
---|
| 257 | states and will buffer traffic for the node based on those sleep |
---|
| 258 | states. When an AP is configured with enable_DTIM_multicast_buffering(False), |
---|
| 259 | it will include the multicast queue in the normal polling of queues, |
---|
| 260 | independent of any STA sleep states. |
---|
| 261 | |
---|
| 262 | Args: |
---|
| 263 | enable (bool): True - enable DTIM multicast buffering |
---|
| 264 | False - disable DTIM multicast buffering |
---|
| 265 | (Default value on Node: True) |
---|
| 266 | """ |
---|
| 267 | self.send_cmd(cmds.NodeAPConfigure(dtim_multicast_buffering=enable)) |
---|
| 268 | |
---|
| 269 | |
---|
| 270 | def set_authentication_address_filter(self, allow): |
---|
| 271 | """Command to set the authentication address filter on the node. |
---|
| 272 | |
---|
| 273 | This command will reset the current address filter and then set the |
---|
| 274 | address filter to the values in the allow list. The filter only affects |
---|
| 275 | over-the-air associations. Assocaitions created by wlan_exp will |
---|
| 276 | bypass any filters configured by this method. |
---|
| 277 | |
---|
| 278 | Clients will be allowed to associate if they pass any of the filters |
---|
| 279 | that are set. |
---|
| 280 | |
---|
| 281 | Args: |
---|
| 282 | allow (list of tuple): List of (address, mask) tuples that will be |
---|
| 283 | used to filter addresses on the node. A tuple can be substituted |
---|
| 284 | with a predefined string: "NONE", "ALL", or "MANGO-W3" |
---|
| 285 | |
---|
| 286 | For the mask, bits that are 0 are treated as "any" and bits that are 1 |
---|
| 287 | are treated as "must equal". For the address, locations of one bits |
---|
| 288 | in the mask must match the incoming addresses to pass the filter. |
---|
| 289 | |
---|
| 290 | Examples: |
---|
| 291 | |
---|
| 292 | * Only allow client with MAC address ``01:23:45:67:89:AB``: |
---|
| 293 | |
---|
| 294 | >>> n_ap.set_authentication_address_filter(allow=(0x0123456789AB, 0xFFFFFFFFFFFF)) |
---|
| 295 | |
---|
| 296 | * Only allow clients with MAC addresses starting with ``01:23:45``: |
---|
| 297 | |
---|
| 298 | >>> n_ap.set_authentication_address_filter(allow=(0x012345000000, 0xFFFFFF000000)) |
---|
| 299 | |
---|
| 300 | * Allow clients with MAC addresses starting with ``01:23:45`` or ``40:`` |
---|
| 301 | |
---|
| 302 | >>> n_ap.set_authentication_address_filter(allow=[(0x012345000000, 0xFFFFFF000000), (0x400000000000, 0xFF0000000000)]) |
---|
| 303 | * Use one of the pre-defined address filter configurations: |
---|
| 304 | |
---|
| 305 | >>> n_ap.set_authentication_address_filter(allow='NONE') # Same as allow=(0x000000000000, 0xFFFFFFFFFFFF) |
---|
| 306 | >>> n_ap.set_authentication_address_filter(allow='ALL') # Same as allow=(0x000000000000, 0x000000000000) |
---|
| 307 | >>> n_ap.set_authentication_address_filter(allow='MANGO-W3') # Same as allow=(0x40d855042000, 0xFFFFFFFFF000) |
---|
| 308 | |
---|
| 309 | """ |
---|
| 310 | filters = [] |
---|
| 311 | |
---|
| 312 | if (type(allow) is not list): |
---|
| 313 | allow = [allow] |
---|
| 314 | |
---|
| 315 | for value in allow: |
---|
| 316 | # Process pre-defined strings |
---|
| 317 | if type(value) is str: |
---|
| 318 | if (value == 'NONE'): |
---|
| 319 | filters.append((0x000000000000, 0xFFFFFFFFFFFF)) |
---|
| 320 | elif (value == 'ALL'): |
---|
| 321 | filters.append((0x000000000000, 0x000000000000)) |
---|
| 322 | elif (value == 'MANGO-W3'): |
---|
| 323 | filters.append((0x40d855042000, 0xFFFFFFFFF000)) |
---|
| 324 | else: |
---|
| 325 | msg = "\n String '{0}' not recognized.".format(value) |
---|
| 326 | msg += "\n Please use 'NONE', 'ALL', 'MANGO-W3' or a (address, mask) tuple" |
---|
| 327 | raise AttributeError(msg) |
---|
| 328 | |
---|
| 329 | elif type(value) is tuple: |
---|
| 330 | import wlan_exp.util as util |
---|
| 331 | |
---|
| 332 | # Process address |
---|
| 333 | if type(value[0]) in [int, long]: |
---|
| 334 | address = value[0] |
---|
| 335 | elif type(value[0]) is str: |
---|
| 336 | try: |
---|
| 337 | address = util.str_to_mac_addr(value[0]) |
---|
| 338 | except: |
---|
| 339 | raise AttributeError("Address {0} is not valid".format(value[0])) |
---|
| 340 | else: |
---|
| 341 | raise AttributeError("Address type {0} is not valid".format(type(value[0]))) |
---|
| 342 | |
---|
| 343 | # Process mask |
---|
| 344 | if type(value[1]) in [int, long]: |
---|
| 345 | mask = value[1] |
---|
| 346 | elif type(value[1]) is str: |
---|
| 347 | try: |
---|
| 348 | mask = util.str_to_mac_addr(value[1]) |
---|
| 349 | except: |
---|
| 350 | raise AttributeError("Mask {0} is not valid".format(value[1])) |
---|
| 351 | else: |
---|
| 352 | raise AttributeError("Mask type {0} is not valid".format(type(value[1]))) |
---|
| 353 | |
---|
| 354 | filters.append((address, mask)) |
---|
| 355 | |
---|
| 356 | else: |
---|
| 357 | msg = "\n Value {0} with type {1} not recognized.".format(value, type(value)) |
---|
| 358 | msg += "\n Please use 'NONE', 'ALL', 'MANGO-W3' or a (address, mask) tuple" |
---|
| 359 | raise AttributeError(msg) |
---|
| 360 | |
---|
| 361 | self.send_cmd(cmds.NodeAPSetAuthAddrFilter(filters)) |
---|
| 362 | |
---|
| 363 | |
---|
| 364 | def add_association(self, device_list, disable_timeout=True): |
---|
| 365 | """Adds each device in ``device_list`` to the list of associated stations at the AP. If a device |
---|
| 366 | is also an 802.11 Reference Design STA, the STA is also configured with the BSS of the AP. In this |
---|
| 367 | case the AP and STA attain the same association state as if they had associated via the standard |
---|
| 368 | wireless handshake. This method bypasses any any authentication address filtering at the AP. |
---|
| 369 | |
---|
| 370 | Args: |
---|
| 371 | device_list (list of WlanExpNode / WlanDevice): List of 802.11 devices |
---|
| 372 | or single 802.11 device to add to the AP's association table |
---|
| 373 | disable_timeout (bool, optional): Disables the AP's normal inactivity timeout for the new associations. |
---|
| 374 | The AP periodically checks for associated stations with no recent Tx/Rx activity and removes inactive |
---|
| 375 | nodes from its list of associated stations. Set this parameter to True to force to AP to keep the new |
---|
| 376 | associations created by this method, even if the stations are inactive. |
---|
| 377 | """ |
---|
| 378 | ret_val = [] |
---|
| 379 | ret_list = True |
---|
| 380 | |
---|
| 381 | # Convert entries to a list if it is not already one |
---|
| 382 | if type(device_list) is not list: |
---|
| 383 | device_list = [device_list] |
---|
| 384 | ret_list = False |
---|
| 385 | |
---|
| 386 | # Get the AP's current Network information |
---|
| 387 | network_info = self.get_network_info() |
---|
| 388 | |
---|
| 389 | if network_info is None: |
---|
| 390 | msg = "\n Cannot add association: AP network configuration is currently null." |
---|
| 391 | msg += "\n Configure the AP's network using configure_bss() before calling add_association()." |
---|
| 392 | raise Exception(msg) |
---|
| 393 | |
---|
| 394 | bssid = network_info['bssid'] |
---|
| 395 | channel = network_info['channel'] |
---|
| 396 | ssid = network_info['ssid'] |
---|
| 397 | beacon_interval = network_info['beacon_interval'] |
---|
| 398 | |
---|
| 399 | if (network_info['ht_capable'] == 1): |
---|
| 400 | ht_capable = True |
---|
| 401 | else: |
---|
| 402 | ht_capable = False |
---|
| 403 | |
---|
| 404 | |
---|
| 405 | if (beacon_interval == 0): |
---|
| 406 | beacon_interval = None |
---|
| 407 | |
---|
| 408 | for device in device_list: |
---|
| 409 | ret_val.append(self._add_association(device=device, bssid=bssid, |
---|
| 410 | channel=channel, ssid=ssid, |
---|
| 411 | beacon_interval=beacon_interval, |
---|
| 412 | ht_capable=ht_capable, |
---|
| 413 | disable_timeout=disable_timeout)) |
---|
| 414 | |
---|
| 415 | # Need to return a single value and not a list |
---|
| 416 | if not ret_list: |
---|
| 417 | ret_val = ret_val[0] |
---|
| 418 | |
---|
| 419 | return ret_val |
---|
| 420 | |
---|
| 421 | |
---|
| 422 | |
---|
| 423 | #------------------------------------------------------------------------- |
---|
| 424 | # Internal AP methods |
---|
| 425 | #------------------------------------------------------------------------- |
---|
| 426 | def _add_association(self, device, bssid, channel, ssid, beacon_interval, ht_capable, disable_timeout): |
---|
| 427 | """Internal command to add an association.""" |
---|
| 428 | ret_val = False |
---|
| 429 | |
---|
| 430 | import wlan_exp.node_ibss as node_ibss |
---|
| 431 | |
---|
| 432 | if isinstance(device, node_ibss.WlanExpNodeIBSS): |
---|
| 433 | print("WARNING: Could not add association for IBSS node '{0}'".format(device.description)) |
---|
| 434 | return ret_val |
---|
| 435 | |
---|
| 436 | aid = self.send_cmd(cmds.NodeAPAddAssociation(device, disable_timeout)) |
---|
| 437 | |
---|
| 438 | if (aid != cmds.CMD_PARAM_ERROR): |
---|
| 439 | import wlan_exp.node_sta as node_sta |
---|
| 440 | |
---|
| 441 | if isinstance(device, node_sta.WlanExpNodeSta): |
---|
| 442 | device.configure_bss(bssid=bssid, ssid=ssid, channel=channel, |
---|
| 443 | beacon_interval=beacon_interval, ht_capable=ht_capable) |
---|
| 444 | device.set_aid(aid=aid) |
---|
| 445 | ret_val = True |
---|
| 446 | else: |
---|
| 447 | msg = "\nWARNING: Device {0} is not a wlan_exp node \n".format(device.description) |
---|
| 448 | msg += " instance. The device has been added to the AP's list of associated stations.\n" |
---|
| 449 | msg += " However, wlan_exp cannot update association state of the device.\n" |
---|
| 450 | print(msg) |
---|
| 451 | ret_val = True |
---|
| 452 | |
---|
| 453 | return ret_val |
---|
| 454 | |
---|
| 455 | # End class |
---|