hitl_tester.test_cases.cyber_6t.fingerprint

Test CAN Profiling
GitHub Issue(s) turnaroundfactor/BMS-HW-Test#268
Description Tests for generating a 6T profile.

(c) 2020-2024 TurnAround Factor, Inc.

#

CUI DISTRIBUTION CONTROL

Controlled by: DLA J68 R&D SBIP

CUI Category: Small Business Research and Technology

Distribution/Dissemination Controls: PROTECTED BY SBIR DATA RIGHTS

POC: GOV SBIP Program Manager Denise Price, 571-767-0111

Distribution authorized to U.S. Government Agencies only, to protect information not owned by the

U.S. Government and protected by a contractor’s ‘limited rights’ statement, or received with the understanding that

it is not routinely transmitted outside the U.S. Government (determination made September 14, 2024). Other requests

for this document shall be referred to ACOR DLA Logistics Operations (J-68), 8725 John J. Kingman Rd., Suite 4317,

Fort Belvoir, VA 22060-6221

#

SBIR DATA RIGHTS

Contract No.:SP4701-23-C-0083

Contractor Name: TurnAround Factor, Inc.

Contractor Address: 10365 Wood Park Ct. Suite 313 / Ashland, VA 23005

Expiration of SBIR Data Rights Period: September 24, 2029

The Government's rights to use, modify, reproduce, release, perform, display, or disclose technical data or computer

software marked with this legend are restricted during the period shown as provided in paragraph (b)(4) of the Rights

in Noncommercial Technical Data and Computer Software--Small Business Innovative Research (SBIR) Program clause

contained in the above identified contract. No restrictions apply after the expiration date shown above. Any

reproduction of technical data, computer software, or portions thereof marked with this legend must also reproduce

the markings.

Used in these test plans:

  • compare ⠀⠀⠀(cyber_6t/compare.plan)
  • fingerprint ⠀⠀⠀(cyber_6t/fingerprint.plan)

Example Command (warning: test plan may run other test cases):

  • ./hitl_tester.py compare -DBATTERY_CHANNEL=can0 -DHARD_RESET=False -DPROFILE="" -DFULL_MEMORY_TESTS=False -DLOG_PATH=/opt/hostedtoolcache/Python/3.12.7/x64/lib/python3.12/site-packages/hitl_tester/logs
   1"""
   2| Test                 | CAN Profiling                                                |
   3| :------------------- | :----------------------------------------------------------- |
   4| GitHub Issue(s)      | turnaroundfactor/BMS-HW-Test#268                             |
   5| Description          | Tests for generating a 6T profile.                           |
   6
   7# (c) 2020-2024 TurnAround Factor, Inc.
   8#
   9# CUI DISTRIBUTION CONTROL
  10# Controlled by: DLA J68 R&D SBIP
  11# CUI Category: Small Business Research and Technology
  12# Distribution/Dissemination Controls: PROTECTED BY SBIR DATA RIGHTS
  13# POC: GOV SBIP Program Manager Denise Price, 571-767-0111
  14# Distribution authorized to U.S. Government Agencies only, to protect information not owned by the
  15# U.S. Government and protected by a contractor’s ‘limited rights’ statement, or received with the understanding that
  16# it is not routinely transmitted outside the U.S. Government (determination made September 14, 2024). Other requests
  17# for this document shall be referred to ACOR DLA Logistics Operations (J-68), 8725 John J. Kingman Rd., Suite 4317,
  18# Fort Belvoir, VA 22060-6221
  19#
  20# SBIR DATA RIGHTS
  21# Contract No.:SP4701-23-C-0083
  22# Contractor Name: TurnAround Factor, Inc.
  23# Contractor Address: 10365 Wood Park Ct. Suite 313 / Ashland, VA 23005
  24# Expiration of SBIR Data Rights Period: September 24, 2029
  25# The Government's rights to use, modify, reproduce, release, perform, display, or disclose technical data or computer
  26# software marked with this legend are restricted during the period shown as provided in paragraph (b)(4) of the Rights
  27# in Noncommercial Technical Data and Computer Software--Small Business Innovative Research (SBIR) Program clause
  28# contained in the above identified contract. No restrictions apply after the expiration date shown above. Any
  29# reproduction of technical data, computer software, or portions thereof marked with this legend must also reproduce
  30# the markings.
  31"""
  32
  33from __future__ import annotations
  34
  35import itertools
  36import json
  37import math
  38import re
  39import time
  40from contextlib import suppress
  41from enum import Enum, IntEnum
  42from pathlib import Path
  43from types import SimpleNamespace
  44from typing import Literal, Any
  45
  46import pytest  # pylint: disable=wrong-import-order
  47from hitl_tester.modules.cyber_6t import charger, spn_types
  48from hitl_tester.modules.cyber_6t.canbus import CANBus, CANFrame
  49from hitl_tester.modules.cyber_6t.j1939da import PGN
  50
  51from hitl_tester.modules import properties
  52from hitl_tester.modules.logger import logger
  53
  54BATTERY_CHANNEL = "can0"
  55"""Channel identification. Expected type is backend dependent."""
  56
  57HARD_RESET = False
  58"""Whether to use a soft or hard reset before each test."""
  59
  60PROFILE = ""
  61"""The name of the profile to compare against. If this is not provided, a profile json will be generated."""
  62
  63FULL_MEMORY_TESTS = False
  64"""Short or long memory tests."""
  65
  66LOG_PATH = Path(__file__).parent.parent.parent.resolve() / "logs"
  67"""Path for profile logs."""
  68
  69properties.apply()  # Allow modifying the above globals
  70
  71_GENERATED_PROFILE = SimpleNamespace()
  72"""Holds the results of each test."""
  73
  74_COMPARISON_PROFILE = SimpleNamespace()
  75"""The profile to compare results too."""
  76
  77
  78class ManufacturerID(IntEnum):
  79    """Enum to hold Manufacturer IDs."""
  80
  81    SAFT = 269  # NOTE: unused
  82    BRENTRONICS = 822
  83
  84
  85class Modes(float, Enum):
  86    """Enum for Command modes"""
  87
  88    ERASE = 0
  89    READ = 1
  90    WRITE = 2
  91    BOOT = 6
  92    EDCP = 7
  93
  94
  95class BadStates(Enum):
  96    """Enum for Bad Test Response States"""
  97
  98    NO_RESPONSE = 0
  99    WRONG_PGN = 1
 100    NACK = 2
 101    WRONG_PACKETS = 3
 102    INVALID_RESPONSE = 4
 103    SKIPPED_TEST = 5
 104
 105
 106class Errors:
 107    """Holds different error messages"""
 108
 109    @staticmethod
 110    def timeout() -> None:
 111        """Prints message when connection times out"""
 112        message = "Could not locate 6T"
 113        logger.write_critical_to_report(message)
 114        pytest.exit(message)
 115
 116    @staticmethod
 117    def unexpected_packet(expected: str, frame: CANFrame) -> None:
 118        """Prints message when unexpected packet is received"""
 119        logger.write_warning_to_report(f"Expected {expected}, got {frame.pgn.short_name}")
 120
 121    @staticmethod
 122    def no_packet(expected: str) -> None:
 123        """Prints message when no packet is received"""
 124        logger.write_warning_to_report(f"Expected {expected}, got None")
 125
 126
 127# FIXME(JA): change issue to the prop issue / name issue
 128
 129
 130def cmp(
 131    a: float | str,
 132    sign: Literal["<", "<=", ">", ">=", "=="],
 133    b: float | str,
 134    unit_a: str = "",
 135    unit_b: str = "",
 136    form: str = "",
 137) -> str:
 138    """Generate a formatted string based on a comparison."""
 139    if not unit_b:
 140        unit_b = unit_a
 141
 142    if isinstance(a, str) or isinstance(b, str):
 143        return f"{a:{form}}{unit_a} {('≠', '=')[a == b]} {b:{form}}{unit_b}"
 144
 145    sign_str = {
 146        "<": ("≮", "<")[a < b],
 147        "<=": ("≰", "≤")[a <= b],
 148        ">": ("≯", ">")[a > b],
 149        ">=": ("≱", "≥")[a >= b],
 150        "==": ("≠", "=")[a == b],
 151    }
 152
 153    return f"{a:{form}}{unit_a} {sign_str[sign]} {b:{form}}{unit_b}"
 154
 155
 156@pytest.fixture(scope="session", autouse=True)
 157def generate_profile():
 158    """Generate a json object for the profile after all tests complete."""
 159    global _COMPARISON_PROFILE
 160
 161    if PROFILE:
 162        _COMPARISON_PROFILE = SimpleNamespace(**json.loads(Path(PROFILE).read_text(encoding="UTF-8")))
 163
 164    yield  # Begin session
 165
 166    if not PROFILE:
 167        try:
 168            manufacturer_name = "".join(filter(str.isalnum, _GENERATED_PROFILE.manufacturer_name))
 169            battery_id = f"{_GENERATED_PROFILE.id:06X}"
 170        except AttributeError:
 171            logger.write_error_to_report("Could not write profile: No name or ID")
 172        else:
 173            profile_path = (Path(LOG_PATH) / "profile") / f"{manufacturer_name}_6T_{battery_id}.json"
 174            profile_path.parent.mkdir(parents=True, exist_ok=True)
 175            profile_path.write_text(json.dumps(_GENERATED_PROFILE, default=vars, indent=4), encoding="UTF-8")
 176            logger.write_info_to_report(f"New Profile: {profile_path.absolute()}")
 177
 178
 179@pytest.fixture(scope="class", autouse=True)
 180def reset_test_environment():
 181    """Before each test class, reset the 6T."""
 182
 183    try:
 184        power_cycle_frame = CANFrame(
 185            destination_address=_GENERATED_PROFILE.address,
 186            pgn=PGN["PropA"],
 187            data=[0, 0, 1, 1, 1, not HARD_RESET, 1, 3, 0, -1],
 188        )
 189        maintenance_mode_frame = CANFrame(
 190            destination_address=_GENERATED_PROFILE.address,
 191            pgn=PGN["PropA"],
 192            data=[0, 0, 1, 1, 1, 3, 1, 3, 0, -1],
 193        )
 194        factory_reset_frame = CANFrame(
 195            destination_address=_GENERATED_PROFILE.address,
 196            pgn=PGN["PropA"],
 197            data=[1, 0, 0, -1, 3, 0xF, 3, 0, 0x1F, -1],
 198        )
 199        with CANBus(BATTERY_CHANNEL) as bus:
 200            logger.write_info_to_report("Power-Cycling 6T")
 201            bus.process_call(power_cycle_frame)
 202            time.sleep(10)
 203            logger.write_info_to_report("Entering maintenance mode")
 204            bus.process_call(maintenance_mode_frame)
 205            logger.write_info_to_report("Factory resetting 6T")
 206            bus.process_call(factory_reset_frame)
 207            time.sleep(10)
 208    except AttributeError:  # Battery has not yet been found
 209        return
 210
 211
 212@pytest.fixture(autouse=True)
 213def check_mode(request):
 214    """Skip comparison tests and output a profile if no comparison file is provided."""
 215    if not PROFILE and request.node.get_closest_marker("profile") is None:
 216        pytest.skip('Mode is "Profile Generation".')
 217
 218
 219class TestLocate6T:
 220    """Scan for 6T battery."""
 221
 222    MAX_ATTEMPTS = 12  # 2 minutes worth of attempts
 223
 224    @pytest.mark.profile
 225    def test_profile(self) -> None:
 226        """
 227        | Description          | Scan the bus for devices                                         |
 228        | :------------------- | :--------------------------------------------------------------- |
 229        | GitHub Issue         | turnaroundfactor/BMS-HW-Test#396                                 |
 230        | Instructions         | 1. Request the names of everyone on the bus                 </br>\
 231                                 2. Use the response data for all communication                   |
 232        | Estimated Duration   | 1 second                                                         |
 233        """
 234        name_request = CANFrame(pgn=PGN["Request"], data=[PGN["Address Claimed"].id])
 235        with CANBus(BATTERY_CHANNEL) as bus:
 236            for attempt in range(self.MAX_ATTEMPTS):
 237                with suppress(IndexError):
 238                    if name_frame := bus.process_call(name_request):
 239                        # Save responses to profile
 240                        _GENERATED_PROFILE.id = int(name_frame.data[0])
 241                        _GENERATED_PROFILE.manufacturer_code = name_frame.data[1]
 242                        if (mfg_name := spn_types.manufacturer_id(name_frame.data[1])) == "Reserved":
 243                            mfg_name = f"Unknown ({name_frame.data[1]})"
 244                        _GENERATED_PROFILE.manufacturer_name = mfg_name
 245                        _GENERATED_PROFILE.address = name_frame.source_address
 246                        break
 247                logger.write_warning_to_html_report(f"Failed to locate 6T. Retrying, attempt {attempt + 1}")
 248                time.sleep(10)
 249            else:
 250                message = "Could not locate 6T"
 251                logger.write_warning_to_html_report(message)
 252                pytest.exit(message)
 253
 254        # Log results
 255        printable_id = "".join(
 256            chr(i) if chr(i).isprintable() else "." for i in _GENERATED_PROFILE.id.to_bytes(3, byteorder="big")
 257        )
 258        logger.write_result_to_html_report(
 259            f"Found {_GENERATED_PROFILE.manufacturer_name} 6T at address {_GENERATED_PROFILE.address} "
 260            f"(ID: {_GENERATED_PROFILE.id:06X}, ASCII ID: {printable_id})"
 261        )
 262
 263
 264class TestProprietaryCommands:
 265    """Scan for proprietary commands"""
 266
 267    @pytest.mark.profile
 268    def test_profile(self) -> None:
 269        """
 270        | Description          | Scan for proprietary commands                                    |
 271        | :------------------- | :--------------------------------------------------------------- |
 272        | GitHub Issue         | turnaroundfactor/BMS-HW-Test#293                                 |
 273        | Instructions         | 1. Request data from all 512 proprietary commands           </br>\
 274                                 2. Log any responses                                             |
 275        | Estimated Duration   | 47 seconds                                                       |
 276        | Note                 | Proprietary commands are unique to each battery type or          \
 277                                 manufacturer, which makes them useful for fingerprinting.        \
 278                                 Especially if undocumented.                                      |
 279        """
 280        ack_responses: dict[int, CANFrame] = {}  # address: response
 281        proprietary_request = CANFrame(destination_address=_GENERATED_PROFILE.address, pgn=PGN["RQST"], data=[0])
 282
 283        with CANBus(BATTERY_CHANNEL) as bus:
 284            # Check all proprietary address ranges (0xEF00-FF00) and (0xFF00, 0x10000)
 285            for address in itertools.chain(range(0xEF00, 0xF000), range(0xFF00, 0x10000)):
 286                proprietary_request.data = [address]
 287                if response_frame := bus.process_call(proprietary_request):  # Log if response is not NACK
 288                    if not (response_frame.pgn.id == PGN["Acknowledgement"].id and response_frame.data[0] == 1):
 289                        ack_responses[address] = response_frame
 290                        logger.write_info_to_report(f"Got response at address {address:04X}: {response_frame}")
 291
 292        # Check conformity to MIL-PRF commands (EF00, FF00-08)
 293        # TODO(JA): Also check format (or should that be a separate test?)
 294        milprf_commands = set(range(0xFF00, 0xFF09))
 295        if milprf := milprf_commands - set(ack_responses):
 296            logger.write_result_to_html_report(f"Missing MIL-PRF commands: {', '.join(map(str, milprf))}")
 297
 298        # Save responses to profile
 299        logger.write_result_to_html_report(
 300            f"Found {len(ack_responses)} commands at: {', '.join(map(lambda i: f'{i:X}', ack_responses))}"
 301        )
 302        _GENERATED_PROFILE.proprietary_commands = ack_responses
 303
 304    def test_comparison(self) -> None:
 305        """
 306        | Description          | Compare proprietary commands                                    |
 307        | :------------------- | :--------------------------------------------------------------- |
 308        | GitHub Issue         | turnaroundfactor/BMS-HW-Test#293                                 |
 309        | Instructions         | 1. Confirm no new commands exit and no commands are missing </br>\
 310                                 2. Check contents of MIL-PRF range is valid                      |
 311        | Pass / Fail Criteria | Pass if we found all commands, and saw no new or malformed ones  |
 312        | Estimated Duration   | 1 second                                                         |
 313        """
 314
 315        comparison_commands = set(map(int, _COMPARISON_PROFILE.proprietary_commands))  # Converted to str by json
 316        missing_commands = comparison_commands - set(_GENERATED_PROFILE.proprietary_commands)
 317        new_commands = set(_GENERATED_PROFILE.proprietary_commands) - comparison_commands
 318
 319        # Check results
 320        logger.write_result_to_html_report(f"Missing: {len(missing_commands)}, New: {len(new_commands)}")
 321        if missing_commands or new_commands:
 322            logger.write_warning_to_html_report(f"Missing commands: {', '.join(map(str, missing_commands))}")
 323            logger.write_warning_to_html_report(f"New Commands: {', '.join(map(str, new_commands))}")
 324            logger.write_failure_to_html_report("Missing or new commands present.")
 325            pytest.fail("Missing or new commands present.")
 326
 327
 328class TestNameInformation:
 329    """Check Unique ID (Identity Number) field in Address Claimed message"""
 330
 331    @pytest.mark.profile
 332    def test_profile(self) -> None:
 333        """
 334        | Description          | Confirm information in NAME (address claimed) matches            \
 335                                 expected value.                                                  |
 336        | :------------------- | :--------------------------------------------------------------- |
 337        | GitHub Issue         | turnaroundfactor/BMS-HW-Test#396                                 |
 338        | Instructions         | 1. Request Address Claimed data                             </br>\
 339                                 2. Log returned values                                      </br>\
 340                                 3. Validate if values meet spec requirements                     |
 341        | Estimated Duration   | 21 seconds                                                       |
 342        """
 343
 344        name_values = {}
 345
 346        name_request = CANFrame(pgn=PGN["Request"], data=[PGN["Address Claimed"].id])
 347        with CANBus(BATTERY_CHANNEL) as bus:
 348            if name_frame := bus.process_call(name_request):
 349                name_values["identity_number"] = int(name_frame.data[0])
 350
 351                if name_values["identity_number"] != _GENERATED_PROFILE.id:
 352                    message = f"Identity Number {name_values} does not match Profile ID: {_GENERATED_PROFILE.id}"
 353                    logger.write_warning_to_html_report(message)
 354
 355                name_values["manufacturer_code"] = name_frame.data[1]
 356                name_values["ecu_instance"] = name_frame.data[2]
 357                name_values["function_instance"] = name_frame.data[3]
 358                name_values["function"] = name_frame.data[4]
 359                name_values["reserved"] = name_frame.data[5]
 360                name_values["vehicle_system"] = name_frame.data[6]
 361                name_values["vehicle_system_instance"] = name_frame.data[7]
 362                name_values["industry_group"] = name_frame.data[8]
 363                name_values["arbitrary_address_capable"] = name_frame.data[9]
 364
 365                for spn, elem in zip(PGN["Address Claimed"].data_field, name_frame.data):
 366                    description = f"({desc})" if (desc := spn.data_type(elem)) else ""
 367                    logger.write_info_to_report(f"{spn.name}: {elem} {description}")
 368
 369                    if spn.name in ("Manufacturer Code", "Reserved"):
 370                        continue
 371
 372                    high_range = 0
 373                    if spn.name == "Identity Number":
 374                        high_range = 2097151
 375
 376                    if spn.name == "ECU Instance":
 377                        high_range = 7
 378
 379                    if spn.name == "Function Instance":
 380                        high_range = 31
 381
 382                    if spn.name == "Function":
 383                        high_range = 254
 384
 385                    if spn.name == "Vehicle System Instance":
 386                        high_range = 15
 387
 388                    if spn.name == "Industry Group":
 389                        high_range = 7
 390
 391                    if spn.name == "Arbitrary Address Capable":
 392                        high_range = 1
 393
 394                    if spn.name == "Vehicle System":
 395                        if (
 396                            name_values["vehicle_system"] != 127
 397                            and name_values["vehicle_system"] != 0
 398                            and name_values["vehicle_system"] != name_values["vehicle_system_instance"]
 399                        ):
 400                            logger.write_warning_to_html_report(
 401                                f"Vehicle System {elem} is not 127, 0, or the same value as " f"Vehicle System Instance"
 402                            )
 403                            _GENERATED_PROFILE.name_violation = "invalid"
 404                        else:
 405                            _GENERATED_PROFILE.name_violation = "valid"
 406
 407                    else:
 408                        if not 0 <= elem <= high_range:
 409                            logger.write_warning_to_html_report(
 410                                f"{spn.name}: {cmp(elem, '>=', 0)} and " f"{cmp(elem, '<=', high_range)}"
 411                            )
 412                            _GENERATED_PROFILE.name_violation = "invalid"
 413                        else:
 414                            _GENERATED_PROFILE.name_violation = "valid"
 415
 416                _GENERATED_PROFILE.name_properties = name_values
 417
 418            else:
 419                message = f"No response to PGN {PGN['Address Claimed', [32]].id} (Address Claimed)"
 420                logger.write_warning_to_html_report(message)
 421                _GENERATED_PROFILE.name_field = BadStates.NO_RESPONSE.name
 422                _GENERATED_PROFILE.name_violation = "invalid"
 423                return
 424
 425        logger.write_result_to_html_report(
 426            f"Found Identity Number: {_GENERATED_PROFILE.id} (0x{_GENERATED_PROFILE.id:06x}) "
 427            f"for {_GENERATED_PROFILE.manufacturer_name} 6T at address {_GENERATED_PROFILE.address}"
 428        )
 429
 430    def test_comparison(self) -> None:
 431        """
 432        | Description          | Compare identity numbers                                         |
 433        | :------------------- | :--------------------------------------------------------------- |
 434        | GitHub Issue         | turnaroundfactor/BMS-HW-Test#396                                 |
 435        | Instructions         | 1. Confirm identity numbers are unique for each battery          |
 436        | Pass / Fail Criteria | Pass if identity numbers are unique for separate batteries       |
 437        | Estimated Duration   | 1 second                                                         |
 438        """
 439        failed_test = False
 440        if not hasattr(_GENERATED_PROFILE, "id") or not hasattr(_COMPARISON_PROFILE, "id"):
 441            logger.write_result_to_html_report("Nothing to compare. The profile test may have failed or been skipped")
 442            pytest.skip("Nothing to compare. The profile test may have failed or been skipped")
 443
 444        logger.write_result_to_html_report(
 445            f"Generated ID: {_GENERATED_PROFILE.id}, " f"Comparison ID: {_COMPARISON_PROFILE.id}"
 446        )
 447        if _GENERATED_PROFILE.id == _COMPARISON_PROFILE.id:
 448            logger.write_warning_to_html_report("Generated IDs are not unique values between batteries.")
 449        else:
 450            logger.write_result_to_html_report("Generated IDs are unique values")
 451
 452        if not hasattr(_GENERATED_PROFILE, "name_violation") or not hasattr(_COMPARISON_PROFILE, "name_violation"):
 453            logger.write_warning_to_html_report("Missing specification information")
 454        else:
 455            comparison = cmp(_GENERATED_PROFILE.name_violation, "==", _COMPARISON_PROFILE.name_violation)
 456            if _GENERATED_PROFILE.name_violation != _COMPARISON_PROFILE.name_violation:
 457                logger.write_failure_to_html_report(f"Specification behavior mismatched: {comparison}")
 458                failed_test = True
 459            else:
 460                logger.write_result_to_html_report(f"Specification behavior matched: {comparison}")
 461
 462        if failed_test:
 463            message = "Comparisons did not meet expectations"
 464            logger.write_failure_to_html_report(message)
 465            pytest.fail(message)
 466
 467
 468class TestSoftwareIdentificationInformation:
 469    """Retrieve & Validate Software Identification Information"""
 470
 471    @pytest.mark.profile
 472    def test_software_identification_information(self) -> None:
 473        """
 474        | Description          | Retrieve Software Identification                                 |
 475        | :------------------- | :--------------------------------------------------------------- |
 476        | GitHub Issue         | turnaroundfactor/BMS-HW-Test#394                                 |
 477        | Instructions         | 1. Request Software Identification                          </br>\
 478                                 2. Validate Software Identification                         </br>\
 479                                 3. Log Response                                                  |
 480        | Estimated Duration   | 21 seconds                                                       |
 481        """
 482
 483        soft_request = CANFrame(pgn=PGN["RQST"], data=[0xFEDA])
 484        soft_data = []
 485        with CANBus(BATTERY_CHANNEL) as bus:
 486            if soft_frame := bus.process_call(soft_request):
 487                # Complete RTS
 488                if len(soft_frame.data) < 3:
 489                    message = (
 490                        f"Unexpected byte response from PGN "
 491                        f"{PGN['Software Identification', [32]].id} (Software Identification)"
 492                    )
 493                    _GENERATED_PROFILE.software_identification_information = BadStates.WRONG_PACKETS.name
 494                    logger.write_warning_to_html_report(message)
 495                    _GENERATED_PROFILE.software_identification_specification = "invalid"
 496                    return
 497                expected_bytes = int(soft_frame.data[1])
 498                expected_packets = 0
 499                if _GENERATED_PROFILE.manufacturer_code == ManufacturerID.BRENTRONICS:
 500                    expected_packets = int(soft_frame.data[2] + 1)
 501                else:
 502                    expected_packets = int(soft_frame.data[2])
 503
 504                rts_pgn_id = int(soft_frame.data[-1])
 505                cts_request = CANFrame(pgn=PGN["TP.CM"], data=[17, 0, 1, 0xFF, 0])
 506                cts_request.data[1] = expected_packets
 507                cts_request.data[-1] = rts_pgn_id
 508
 509                # Send CTS
 510                bus.send_message(cts_request.message())
 511
 512                # Read & Store data from packets
 513                for i in range(expected_packets):
 514                    data_frame = bus.read_frame()
 515                    if data_frame.pgn.id != 0xEB00:
 516                        message = f"Expected data frame {i + 1}, got PGN {data_frame.pgn.id}"
 517                        _GENERATED_PROFILE.software_identification_information = BadStates.WRONG_PACKETS.name
 518                        _GENERATED_PROFILE.software_identification_specification = "invalid"
 519                        logger.write_warning_to_html_report(message)
 520                        return
 521                    soft_data.append(hex(int(data_frame.data[1])))
 522
 523                if len(soft_data) != expected_packets:
 524                    message = f"Expected {expected_packets} packets, got {len(soft_data)}"
 525                    logger.write_warning_to_html_report(message)
 526                    _GENERATED_PROFILE.software_identification_specification = "invalid"
 527                    _GENERATED_PROFILE.software_identification_information = BadStates.WRONG_PACKETS.name
 528                    return
 529
 530                # Send acknowledgement frame
 531                end_acknowledge_frame = CANFrame(pgn=PGN["TP.CM"], data=[19, 0, 0, 0xFF, 0])
 532                end_acknowledge_frame.data[1] = expected_bytes
 533                end_acknowledge_frame.data[2] = expected_packets
 534                end_acknowledge_frame.data[-1] = rts_pgn_id
 535
 536                bus.send_message(end_acknowledge_frame.message())
 537            else:
 538                message = f"No response for PGN {PGN['Software Identification', [32]].id} (Software Identification)"
 539                logger.write_warning_to_html_report(message)
 540                _GENERATED_PROFILE.software_identification_information = BadStates.NO_RESPONSE.name
 541                _GENERATED_PROFILE.software_identification_specification = "invalid"
 542                return
 543
 544        hex_string = "0x"
 545        add_to_data = False
 546        finish_adding = False
 547
 548        # Find Software Identification Number
 549        for element in soft_data:
 550            n = len(element) - 1
 551            while n >= 1:
 552                if add_to_data:
 553                    if element[n - 1 : n + 1] == "2a":
 554                        finish_adding = True
 555                        break
 556                    if element[n - 1 : n + 1] != "0x":
 557                        hex_string += element[n - 1 : n + 1]
 558                        n -= 2
 559                    else:
 560                        n -= 2
 561                else:
 562                    if element[n - 3 : n + 1] == "2a31" or element[n - 3 : n + 1] == "2a01":
 563                        add_to_data = True
 564                        n -= 4
 565                    else:
 566                        n -= 4
 567            if finish_adding:
 568                break
 569
 570        if len(hex_string) <= 2:
 571            logger.write_warning_to_html_report(
 572                f"Software Identification: {hex_string} was not found in expected format"
 573            )
 574            _GENERATED_PROFILE.software_identification_information = hex_string
 575            _GENERATED_PROFILE.software_identification_specification = "invalid"
 576            return
 577
 578        software_identification = spn_types.ascii_map(int(hex_string, 16))
 579
 580        if re.search(r"[0-9]{2}\.[0-9]{2}\.[a-z]{2}\.[a-z]{2,}", software_identification) is None:
 581            message = f"Software Identification: {software_identification} was not in expected format: MM.II.mm.aa.ee"
 582            logger.write_warning_to_html_report(message)
 583            _GENERATED_PROFILE.software_identification_specification = "invalid"
 584        else:
 585            _GENERATED_PROFILE.software_identification_specification = "valid"
 586
 587        _GENERATED_PROFILE.software_identification = software_identification
 588
 589        logger.write_result_to_html_report(f"Software Identification: {software_identification}")
 590
 591    def test_comparison(self) -> None:
 592        """
 593        | Description          | Compare Software Identification values                           |
 594        | :------------------- | :--------------------------------------------------------------- |
 595        | GitHub Issue         | turnaroundfactor/BMS-HW-Test#394                                 |
 596        | Instructions         | 1. Confirm software versions are the same for each profile       |
 597        | Pass / Fail Criteria | Pass if identity numbers match for each profile                  |
 598        | Estimated Duration   | 1 second                                                         |
 599        """
 600        failed_test = False
 601        if not hasattr(_GENERATED_PROFILE, "software_identification") or not hasattr(
 602            _COMPARISON_PROFILE, "software_identification"
 603        ):
 604            message = "Nothing to compare. The profile test may have failed or been skipped"
 605            logger.write_result_to_html_report(message)
 606            pytest.skip(message)
 607
 608        compare = cmp(_GENERATED_PROFILE.software_identification, "==", _COMPARISON_PROFILE.software_identification)
 609        if _GENERATED_PROFILE.software_identification != _COMPARISON_PROFILE.software_identification:
 610            logger.write_failure_to_html_report(f"Software Identification: {compare}")
 611            failed_test = True
 612        else:
 613            logger.write_result_to_html_report(f"Software Identification: {compare}")
 614
 615        if not hasattr(_GENERATED_PROFILE, "software_identification_specification") or not hasattr(
 616            _COMPARISON_PROFILE, "software_identification_specification"
 617        ):
 618            logger.write_warning_to_html_report("Missing specification information")
 619        else:
 620            comparison = cmp(
 621                _GENERATED_PROFILE.software_identification_specification,
 622                "==",
 623                _COMPARISON_PROFILE.software_identification_specification,
 624            )
 625            if (
 626                _GENERATED_PROFILE.software_identification_specification
 627                != _COMPARISON_PROFILE.software_identification_specification
 628            ):
 629                logger.write_failure_to_html_report(f"Specification comparison failed: {comparison}")
 630                failed_test = True
 631            else:
 632                logger.write_result_to_html_report(f"Specification comparison passed: {comparison}")
 633
 634        if failed_test:
 635            message = "Comparisons did not match in profiles"
 636            logger.write_failure_to_html_report(message)
 637            pytest.fail(message)
 638
 639
 640class TestBatteryRegulationInformation:
 641    """Retrieve and validate battery regulation information"""
 642
 643    @pytest.mark.profile
 644    def test_profile(self) -> None:
 645        """
 646        | Description          | Obtain Battery Regulation Information 1 & 2                      |
 647        | :------------------- | :--------------------------------------------------------------- |
 648        | GitHub Issue         | turnaroundfactor/BMS-HW-Test#392                                 |
 649        | Instructions         | 1. Request Battery Regulation Information 1                 </br>\
 650                                 2. Validate Information                                     </br>\
 651                                 3. Log Response                                                  |
 652        | Estimated Duration   | 22 seconds                                                       |
 653        """
 654
 655        battery_regulation_one_request = CANFrame(pgn=PGN["RQST"], data=[0xFF03])
 656        battery_regulation_two_request = CANFrame(pgn=PGN["RQST"], data=[0xFF04])
 657
 658        # Obtain Battery Regulation One Information
 659        with CANBus(BATTERY_CHANNEL) as bus:
 660            if battery_frame := bus.process_call(battery_regulation_one_request):
 661                logger.write_result_to_html_report(
 662                    "<span style='font-weight: bold'>Battery Regulation Information 1 </span>"
 663                )
 664                if battery_frame.pgn.id != PGN["PropB_03", [32]].id:
 665                    message = f"Expected {PGN['PropB_03', [32]].id} 0xFF03, but received PGN {battery_frame.pgn.id}"
 666                    logger.write_warning_to_html_report(message)
 667                    _GENERATED_PROFILE.battery_regulation_one = BadStates.WRONG_PGN.name
 668                    _GENERATED_PROFILE.battery_regulation_one_specification = "invalid"
 669                else:
 670                    _GENERATED_PROFILE.battery_regulation_one = {
 671                        "battery_voltage": battery_frame.data[0],
 672                        "open_circuit_voltage": battery_frame.data[1],
 673                        "battery_current": battery_frame.data[2],
 674                        "maximum_charge_current": battery_frame.data[3],
 675                    }
 676
 677                    for spn, elem in zip(PGN["PropB_03"].data_field, battery_frame.data):
 678                        description = f"({desc})" if (desc := spn.data_type(elem)) else ""
 679                        logger.write_result_to_html_report(f"{spn.name}: {elem} {description}")
 680
 681                        high_range = 3212.75
 682                        low_range = 0.0
 683                        if spn.name == "Battery Current":
 684                            high_range = 1600.00
 685                            low_range = -1600.00
 686
 687                        if not low_range <= elem <= high_range:
 688                            logger.write_warning_to_html_report(
 689                                f"{spn.name}: {cmp(elem, '>=', low_range)} and " f"{cmp(elem, '<=', high_range)}"
 690                            )
 691                            _GENERATED_PROFILE.battery_regulation_one_specification = "invalid"
 692
 693                    if not hasattr(_GENERATED_PROFILE, "battery_regulation_one_specification"):
 694                        _GENERATED_PROFILE.battery_regulation_one_specification = "valid"
 695
 696            else:
 697                message = f"Did not receive response for PGN {PGN['PropB_03', [32]].id} (Battery Regulation One)"
 698                logger.write_warning_to_html_report(message)
 699                _GENERATED_PROFILE.battery_regulation_one = BadStates.NO_RESPONSE.name
 700                _GENERATED_PROFILE.battery_regulation_one_specification = "invalid"
 701
 702        # Obtain Battery Regulation Two Information
 703        with CANBus(BATTERY_CHANNEL) as bus:
 704            if battery_frame_two := bus.process_call(battery_regulation_two_request):
 705                logger.write_result_to_html_report(
 706                    "<span style='font-weight: bold'>Battery Regulation Information 2</span>"
 707                )
 708
 709                if battery_frame_two.pgn.id != PGN["PropB_04", [32]].id:
 710                    message = f"Expected PGN {PGN['PropB_04', [32]].id}, but received PGN {battery_frame_two.pgn.id}"
 711                    logger.write_warning_to_html_report(message)
 712                    _GENERATED_PROFILE.battery_regulation_two = BadStates.NO_RESPONSE.name
 713                    _GENERATED_PROFILE.battery_regulation_two_specification = "invalid"
 714                else:
 715                    _GENERATED_PROFILE.battery_regulation_two = {
 716                        "contractor_state": battery_frame_two.data[0],
 717                        "charge_capability_state": battery_frame_two.data[1],
 718                        "bus_voltage_request": battery_frame_two.data[3],
 719                        "transportability_soc": battery_frame_two.data[4],
 720                    }
 721
 722                    for spn, elem in zip(PGN["PropB_04"].data_field, battery_frame_two.data):
 723
 724                        if spn.name == "Reserved":
 725                            continue
 726
 727                        description = f"({desc})" if (desc := spn.data_type(elem)) else ""
 728                        logger.write_result_to_html_report(f"{spn.name}: {elem} {description}")
 729
 730                        high_range = 3
 731                        if spn.name == "Bus Voltage Request":
 732                            high_range = 3212.75
 733
 734                        if spn.name == "Transportability SOC":
 735                            high_range = 100
 736
 737                        if not 0 <= elem <= high_range:
 738                            logger.write_warning_to_html_report(
 739                                f"{spn.name}: {cmp(elem, '>=', 0)} and {cmp(elem, '<=', high_range)}"
 740                            )
 741                            _GENERATED_PROFILE.battery_regulation_two_specification = "invalid"
 742
 743                    if not hasattr(_GENERATED_PROFILE, "battery_regulation_two_specification"):
 744                        _GENERATED_PROFILE.battery_regulation_two_specification = "valid"
 745
 746            else:
 747                message = f"Did not receive response for PGN {PGN['PropB_04', [32]].id} (Battery Regulation Two)"
 748                logger.write_warning_to_html_report(message)
 749                _GENERATED_PROFILE.battery_regulation_two = BadStates.NO_RESPONSE.name
 750                _GENERATED_PROFILE.battery_regulation_two_specification = "invalid"
 751
 752    # def test_comparison(self) -> None:
 753    #     # TODO: FIX
 754    #     """
 755    #     | Description          | Compare Battery Regulation values                                |
 756    #     | :------------------- | :--------------------------------------------------------------- |
 757    #     | GitHub Issue         | turnaroundfactor/BMS-HW-Test#394                                 |
 758    #     | Instructions         | 1. Confirm Battery Regulation One profiles match            </br>\
 759    #                              2. Confirm Battery Regulation Two profiles match                 |
 760    #     | Pass / Fail Criteria | Pass if Battery Regulation values match for each profile         |
 761    #     | Estimated Duration   | 1 second                                                         |
 762    #     """
 763    #
 764    #     def loop_profiles(generated, comparison) -> list[str]:
 765    #         fail_list = []
 766    #         for key, value in generated.items():
 767    #             key_text = key.replace("_", " ").title()
 768    #             message = cmp(value, "==", comparison[key])
 769    #             if comparison[key] != value:
 770    #                 logger.write_warning_to_html_report(f"{key_text}: {message}")
 771    #                 fail_list.append(key_text)
 772    #             else:
 773    #                 logger.write_result_to_html_report(f"{key_text}: {message}")
 774    #
 775    #         return fail_list
 776    #
 777    #     test_failed = False
 778    #     reg_one_list: list[str] = []
 779    #     reg_two_list: list[str] = []
 780    #     if not hasattr(_GENERATED_PROFILE, "battery_regulation_one") or not hasattr(
 781    #         _COMPARISON_PROFILE, "battery_regulation_one"
 782    #     ):
 783    #         message = "Nothing to compare. The profile test may have failed or been skipped"
 784    #         logger.write_result_to_html_report(message)
 785    #         pytest.skip(message)
 786    #
 787    #     if not hasattr(_GENERATED_PROFILE, "battery_regulation_two") or not hasattr(
 788    #         _COMPARISON_PROFILE, "battery_regulation_two"
 789    #     ):
 790    #         message = "Nothing to compare. The profile test may have failed or been skipped"
 791    #         logger.write_result_to_html_report(message)
 792    #         pytest.skip(message)
 793    #
 794    #     logger.write_result_to_html_report(
 795    #     "<span style='font-weight: bold'>Battery Regulation One Comparisons</span>"
 796    #     )
 797    #     if isinstance(_GENERATED_PROFILE.battery_regulation_one, str) or isinstance(
 798    #         _COMPARISON_PROFILE.battery_regulation_one, str
 799    #     ):
 800    #         if not isinstance(
 801    #             _GENERATED_PROFILE.battery_regulation_one, type(_COMPARISON_PROFILE.battery_regulation_one)
 802    #         ):
 803    #             message = "Unable to compare profiles, Battery Regulation One types did not match"
 804    #             logger.write_warning_to_html_report(message)
 805    #             pytest.fail(message)
 806    #
 807    #         comparison_text = cmp(
 808    #             _GENERATED_PROFILE.battery_regulation_one, "==", _COMPARISON_PROFILE.battery_regulation_one
 809    #         )
 810    #
 811    #         if _GENERATED_PROFILE.battery_regulation_one != _COMPARISON_PROFILE.battery_regulation_one:
 812    #             message = f"Battery Regulation One did not match: {comparison_text}"
 813    #             logger.write_warning_to_html_report(message)
 814    #             test_failed = True
 815    #     else:
 816    #         reg_one_list = loop_profiles(
 817    #             _GENERATED_PROFILE.battery_regulation_one, _COMPARISON_PROFILE.battery_regulation_one
 818    #         )
 819    #
 820    #     logger.write_result_to_html_report(
 821    #       "<span style='font-weight: bold'>Battery Regulation Two Comparisons</span>"
 822    #     )
 823    #     if isinstance(_GENERATED_PROFILE.battery_regulation_two, str) or isinstance(
 824    #         _COMPARISON_PROFILE.battery_regulation_two, str
 825    #     ):
 826    #         if not isinstance(
 827    #             _GENERATED_PROFILE.battery_regulation_two, type(_COMPARISON_PROFILE.battery_regulation_two)
 828    #         ):
 829    #             message = "Unable to compare profiles, Battery Regulation One types did not match"
 830    #             logger.write_warning_to_html_report(message)
 831    #             pytest.fail(message)
 832    #
 833    #         comparison_text = cmp(
 834    #             _GENERATED_PROFILE.battery_regulation_two, "==", _COMPARISON_PROFILE.battery_regulation_two
 835    #         )
 836    #
 837    #         if _GENERATED_PROFILE.battery_regulation_two != _COMPARISON_PROFILE.battery_regulation_two:
 838    #             message = f"Battery Regulation Two did not match: {comparison_text}"
 839    #             logger.write_warning_to_html_report(message)
 840    #             test_failed = True
 841    #     else:
 842    #         reg_two_list = loop_profiles(
 843    #             _GENERATED_PROFILE.battery_regulation_two, _COMPARISON_PROFILE.battery_regulation_two
 844    #         )
 845    #
 846    #     if test_failed:
 847    #         message = "Battery Regulation values did not match"
 848    #         logger.write_failure_to_html_report(message)
 849    #         pytest.fail(message)
 850    #
 851    #     fail_categories = reg_one_list + reg_two_list
 852    #
 853    #     if len(fail_categories) > 0:
 854    #         categories = "data values" if len(fail_categories) > 1 else "data value"
 855    #         message = f"{len(fail_categories)} {categories} failed profile comparisons: {', '.join(fail_categories)}"
 856    #         logger.write_failure_to_html_report(message)
 857    #         pytest.fail(message)
 858    #     else:
 859    #         logger.write_result_to_html_report("Battery Regulation 1 and 2 profiles match")
 860
 861
 862class TestPowerSupply:
 863    """Retrieve and validate battery regulation information"""
 864
 865    @pytest.mark.profile
 866    def test_charge_power_supply(self) -> None:
 867        """
 868        | Description          | Check Battery Information after Charge                           |
 869        | :------------------- | :--------------------------------------------------------------- |
 870        | GitHub Issue         | turnaroundfactor/BMS-HW-Test#597                                 |
 871        | Instructions         | 1. Request Battery State of Charge                          </br>\
 872                                 2. Charge battery at 0.1A for 1 minute                      </br>\
 873                                 3. Check & save Battery Regulation 1 & 2 information        </br>\
 874                                 4. Log Response                                                  |
 875        | Estimated Duration   | 2 minutes                                                        |
 876        """
 877
 878        battery_calculation_one = CANFrame(pgn=PGN["RQST"], data=[PGN["PropB_02", [32]].id])
 879
 880        with CANBus(BATTERY_CHANNEL) as bus:
 881            if battery_calculation_response := bus.process_call(battery_calculation_one):
 882                if battery_calculation_response.pgn.id != PGN["PropB_02", [32]].id:
 883                    message = (
 884                        f"Expected PGN {PGN['PropB_02', [32]].id} ({PGN['PropB_02', [32]].name}) "
 885                        f" but received PGN {battery_calculation_response.pgn.id}"
 886                    )
 887                    logger.write_warning_to_html_report(message)
 888                    _GENERATED_PROFILE.test_power_supply = BadStates.WRONG_PGN.name
 889                    pytest.skip(message)
 890
 891                for spn, elem in zip(PGN["PropB_02"].data_field, battery_calculation_response.data):
 892                    low_range = 0
 893                    high_range = 95
 894                    if spn.name == "Battery State of Charge":
 895                        comparison = f"{cmp(elem, '>=', low_range, '%')} " f"and {cmp(elem, '<=', high_range, '%' )}"
 896
 897                        if not low_range <= elem <= high_range:
 898                            message = (
 899                                f"Skipping Test: {spn.name} is {elem}%, "
 900                                f"which is not within expected range: {comparison}"
 901                            )
 902                            logger.write_warning_to_html_report(message)
 903                            _GENERATED_PROFILE.test_power_supply = BadStates.SKIPPED_TEST.name
 904                            pytest.skip(message)
 905                        else:
 906                            message = f"{spn.name} is {elem}%"
 907                            logger.write_result_to_html_report(message)
 908                        break
 909            else:
 910                message = f"Did not receive response for PGN {PGN['PropB_02', [32]].id} ({PGN['PropB_02', [32]].name})"
 911                logger.write_warning_to_html_report(message)
 912                _GENERATED_PROFILE.test_power_supply = BadStates.NO_RESPONSE.name
 913                pytest.skip(message)
 914
 915            if not hasattr(_GENERATED_PROFILE, "battery_regulation_one") and not hasattr(
 916                _GENERATED_PROFILE, "battery_regulation_two"
 917            ):
 918                default_b_one = self.get_battery_regulation_one()
 919                default_b_two = self.get_battery_regulation_two()
 920            else:
 921                default_b_one = _GENERATED_PROFILE.battery_regulation_one
 922                default_b_two = _GENERATED_PROFILE.battery_regulation_two
 923
 924            if isinstance(default_b_one, str) or isinstance(default_b_two, str):
 925                _GENERATED_PROFILE.test_power_supply = BadStates.INVALID_RESPONSE.name
 926                logger.write_warning_to_html_report("Missing valid Battery Regulation One and Two info")
 927                pytest.skip("Missing valid Battery Regulation One and Two info")
 928
 929            logger.write_result_to_html_report("Charging battery")
 930            try:
 931                cyber_charger = charger.Charger()
 932            except RuntimeError:
 933                message = "Could not communicate with power supply"
 934                logger.write_warning_to_html_report(message)
 935                pytest.fail(message)
 936
 937            try:
 938                with cyber_charger(0.1, float(default_b_two.get("bus_voltage_request", 0.0))):
 939                    logger.write_info_to_report("Battery Regulation One Values from Charge")
 940                    updated_b_one = self.get_battery_regulation_one()
 941                    logger.write_info_to_report("Battery Regulation Two Values from Charge")
 942                    updated_b_two = self.get_battery_regulation_two()
 943
 944                    if isinstance(updated_b_one, str):
 945                        if updated_b_one == "No Current":
 946                            _GENERATED_PROFILE.test_charge_power_supply = "Current not detected"
 947                            raise RuntimeError
 948
 949                        _GENERATED_PROFILE.test_power_supply = BadStates.INVALID_RESPONSE.name
 950                        logger.write_warning_to_html_report("Missing valid Battery Regulation One and Two info")
 951                        pytest.skip("Missing valid Battery Regulation One and Two info")
 952
 953                    _GENERATED_PROFILE.charge_b_one = updated_b_one
 954                    _GENERATED_PROFILE.charge_b_two = updated_b_two
 955
 956            except RuntimeError:
 957                if cyber_charger.resource:
 958                    if hasattr(cyber_charger.resource, "close"):
 959                        cyber_charger.resource.close()
 960                message = "Could not communicate with power supply or current not detected"
 961                logger.write_warning_to_html_report(message)
 962
 963                if not hasattr(_GENERATED_PROFILE, "test_charge_power_supply"):
 964                    _GENERATED_PROFILE.test_charge_power_supply = "Could not communicate"
 965                pytest.fail(message)
 966
 967            except AttributeError:
 968                message = "Could not communicate with power supply"
 969                logger.write_warning_to_html_report(message)
 970                _GENERATED_PROFILE.test_discharge_power_supply = "Could not communicate"
 971                pytest.fail(message)
 972
 973    def test_charge_comparison(self):
 974        """
 975        | Description          | Compare Power Supply Charge values                               |
 976        | :------------------- | :--------------------------------------------------------------- |
 977        | GitHub Issue         | turnaroundfactor/BMS-HW-Test#393                                 |
 978        | Instructions         | 1. Check if charge Battery Current is within 10% of initial value|
 979        | Pass / Fail Criteria | Pass if charged value is within 10%                              |
 980        | Estimated Duration   | 1 second                                                         |
 981        """
 982
 983        if hasattr(_GENERATED_PROFILE, "test_power_supply") or not hasattr(_GENERATED_PROFILE, "charge_b_one"):
 984            message = "Nothing to compare. Profile test may have failed or been skipped"
 985            logger.write_result_to_html_report(message)
 986            pytest.skip(message)
 987
 988        if hasattr(_COMPARISON_PROFILE, "test_power_supply") or not hasattr(_COMPARISON_PROFILE, "charge_b_one"):
 989            message = "Nothing to compare. Profile test may have failed or been skipped"
 990            logger.write_result_to_html_report(message)
 991            pytest.skip(message)
 992
 993        if isinstance(_GENERATED_PROFILE.charge_b_one, str) or isinstance(_COMPARISON_PROFILE.charge_b_one, str):
 994            if not isinstance(_GENERATED_PROFILE.charge_b_one, type(_COMPARISON_PROFILE.charge_b_one)):
 995                message = "Unable to compare profiles, types of Battery Regulation Information 1 did not match"
 996                logger.write_failure_to_html_report(message)
 997                pytest.fail(message)
 998
 999            comparison = cmp(_GENERATED_PROFILE.charge_b_one, "==", _COMPARISON_PROFILE.charge_b_one)
1000
1001            if _GENERATED_PROFILE.charge_b_one != _COMPARISON_PROFILE.charge_b_one:
1002                message = f"Battery Regulation Information 1 matched profiles: {comparison}"
1003                logger.write_failure_to_html_report(message)
1004                pytest.fail(message)
1005
1006            message = f"Battery Regulation Information 1 matched: {comparison}"
1007            logger.write_result_to_html_report(message)
1008            return
1009
1010        original_current = _GENERATED_PROFILE.charge_b_one.get("battery_current")
1011        comparison_current = _COMPARISON_PROFILE.charge_b_one.get("battery_current")
1012
1013        diff = abs(original_current - comparison_current)
1014        average = abs(original_current + comparison_current) / 2
1015        percentage = round((diff / average), 4)
1016        percent_closeness = 0.1
1017
1018        comparison = cmp(percentage * 100, "<=", percent_closeness * 100, "%")
1019
1020        if not math.isclose(original_current, comparison_current, rel_tol=0.1):
1021            logger.write_warning_to_html_report(
1022                f"Battery Regulation Information 1 current percent difference: {comparison}"
1023            )
1024            logger.write_failure_to_html_report("Current was not within expected range during charge")
1025            pytest.fail("Current was not within expected range during charge")
1026
1027        logger.write_result_to_html_report(f"Battery Regulation Information 1 current percent difference: {comparison}")
1028        logger.write_result_to_html_report("Current was within expected range during charge")
1029
1030    @pytest.mark.profile
1031    def test_discharge_power_supply(self) -> None:
1032        """
1033        | Description          | Check Battery Information after Discharge                        |
1034        | :------------------- | :--------------------------------------------------------------- |
1035        | GitHub Issue         | turnaroundfactor/BMS-HW-Test#597                                 |
1036        | Instructions         | 1. Request Battery State of Charge                          </br>\
1037                                 2. Discharge battery at -0.1A for 1 minute                  </br>\
1038                                 3. Check & save Battery Regulation 1 & 2 information        </br>\
1039                                 4. Log Response                                                  |
1040        | Estimated Duration   | 2 minutes                                                        |
1041        """
1042
1043        battery_calculation_one = CANFrame(pgn=PGN["RQST"], data=[PGN["PropB_02", [32]].id])
1044
1045        with CANBus(BATTERY_CHANNEL) as bus:
1046            if battery_calculation_response := bus.process_call(battery_calculation_one):
1047                if battery_calculation_response.pgn.id != PGN["PropB_02", [32]].id:
1048                    message = (
1049                        f"Expected PGN {PGN['PropB_02', [32]].id} ({PGN['PropB_02', [32]].name}) "
1050                        f" but received PGN {battery_calculation_response.pgn.id}"
1051                    )
1052                    logger.write_warning_to_html_report(message)
1053                    _GENERATED_PROFILE.test_discharge_power_supply = BadStates.WRONG_PGN.name
1054                    return
1055
1056                for spn, elem in zip(PGN["PropB_02"].data_field, battery_calculation_response.data):
1057                    if spn.name == "Battery State of Charge":
1058                        low_range = 5
1059                        high_range = 100
1060                        comparison = f"{cmp(elem, '>=', low_range, '%')} and {cmp(elem, '<=', high_range, '%' )}"
1061
1062                        if not low_range <= elem <= high_range:
1063                            message = (
1064                                f"Skipping Test: {spn.name} is {elem}%, "
1065                                f"which is not within expected range: {comparison}"
1066                            )
1067                            logger.write_warning_to_html_report(message)
1068                            _GENERATED_PROFILE.test_discharge_power_supply = BadStates.SKIPPED_TEST.name
1069
1070                            pytest.skip(message)
1071                        else:
1072                            message = f"{spn.name} is {elem}%"
1073                            logger.write_result_to_html_report(message)
1074                        break
1075            else:
1076                message = f"Did not receive response for PGN {PGN['PropB_02', [32]].id} ({PGN['PropB_02', [32]].name})"
1077                logger.write_warning_to_html_report(message)
1078                _GENERATED_PROFILE.test_discharge_power_supply = BadStates.NO_RESPONSE.name
1079                pytest.skip(message)
1080
1081            if not hasattr(_GENERATED_PROFILE, "battery_regulation_one") and not hasattr(
1082                _GENERATED_PROFILE, "battery_regulation_two"
1083            ):
1084                default_b_one = self.get_battery_regulation_one()
1085                default_b_two = self.get_battery_regulation_two()
1086            else:
1087                default_b_one = _GENERATED_PROFILE.battery_regulation_one
1088                default_b_two = _GENERATED_PROFILE.battery_regulation_two
1089
1090            if isinstance(default_b_one, str) or isinstance(default_b_two, str):
1091                _GENERATED_PROFILE.test_discharge_power_supply = BadStates.INVALID_RESPONSE.name
1092
1093                logger.write_warning_to_html_report("Missing valid Battery Regulation One and Two info")
1094                pytest.skip("Missing valid Battery Regulation One and Two info")
1095
1096            logger.write_result_to_html_report("Discharging battery")
1097            try:
1098                cyber_charger = charger.Charger()
1099            except RuntimeError:
1100                message = "Could not communicate with power supply"
1101                logger.write_warning_to_html_report(message)
1102                pytest.fail(message)
1103            try:
1104                with cyber_charger(-0.1):
1105                    logger.write_info_to_report("Battery Regulation One Values from Discharge")
1106                    updated_b_one = self.get_battery_regulation_one()
1107                    logger.write_info_to_report("Battery Regulation Two Values from Discharge")
1108                    updated_b_two = self.get_battery_regulation_two()
1109
1110                    if isinstance(updated_b_one, str):
1111                        if updated_b_one == "No Current":
1112                            _GENERATED_PROFILE.test_discharge_power_supply = "Current not detected"
1113                            raise RuntimeError
1114
1115                        _GENERATED_PROFILE.test_power_supply = BadStates.INVALID_RESPONSE.name
1116                        logger.write_warning_to_html_report("Missing valid Battery Regulation One and Two info")
1117                        pytest.skip("Missing valid Battery Regulation One and Two info")
1118
1119                    _GENERATED_PROFILE.discharge_b_one = updated_b_one
1120                    _GENERATED_PROFILE.discharge_b_two = updated_b_two
1121
1122            except RuntimeError:
1123                if cyber_charger.resource:
1124                    if hasattr(cyber_charger.resource, "close"):
1125                        cyber_charger.resource.close()
1126                message = "Could not communicate with power supply or current not detected"
1127                logger.write_warning_to_html_report(message)
1128                if not hasattr(_GENERATED_PROFILE, "test_charge_power_supply"):
1129                    _GENERATED_PROFILE.test_discharge_power_supply = "Could not communicate"
1130                pytest.fail(message)
1131
1132            except AttributeError:
1133                message = "Could not communicate with power supply"
1134                logger.write_warning_to_html_report(message)
1135                _GENERATED_PROFILE.test_discharge_power_supply = "Could not communicate"
1136                pytest.fail(message)
1137
1138    def test_discharge_comparison(self):
1139        """
1140        | Description          | Compare Power Supply Discharge values                            |
1141        | :------------------- | :--------------------------------------------------------------- |
1142        | GitHub Issue         | turnaroundfactor/BMS-HW-Test#393                                 |
1143        | Instructions         | 1. Check if discharge Battery Current is within 10% of initial value|
1144        | Pass / Fail Criteria | Pass if discharged value is within 10%                           |
1145        | Estimated Duration   | 1 second                                                         |
1146        """
1147
1148        if hasattr(_GENERATED_PROFILE, "test_discharge_power_supply") or not hasattr(
1149            _GENERATED_PROFILE, "discharge_b_one"
1150        ):
1151            message = "Nothing to compare. The profile test may have failed or been skipped"
1152            logger.write_result_to_html_report(message)
1153            pytest.skip(message)
1154
1155        if hasattr(_COMPARISON_PROFILE, "test_discharge_power_supply") or not hasattr(
1156            _COMPARISON_PROFILE, "discharge_b_one"
1157        ):
1158            message = "Nothing to compare. The profile test may have failed or been skipped"
1159            logger.write_result_to_html_report(message)
1160            pytest.skip(message)
1161
1162        if isinstance(_GENERATED_PROFILE.discharge_b_one, str) or isinstance(_COMPARISON_PROFILE.discharge_b_one, str):
1163            if not isinstance(_GENERATED_PROFILE.discharge_b_one, type(_COMPARISON_PROFILE.discharge_b_one)):
1164                message = "Unable to compare profiles, types of Battery Regulation Information 1 did not match"
1165                logger.write_failure_to_html_report(message)
1166                pytest.fail(message)
1167
1168            comparison = cmp(_GENERATED_PROFILE.discharge_b_one, "==", _COMPARISON_PROFILE.discharge_b_one)
1169
1170            if _GENERATED_PROFILE.discharge_b_one != _COMPARISON_PROFILE.discharge_b_one:
1171                message = f"Battery Regulation Information 1 matched profiles: {comparison}"
1172                logger.write_failure_to_html_report(message)
1173                pytest.fail(message)
1174
1175            message = f"Battery Regulation Information 1 matched: {comparison}"
1176            logger.write_result_to_html_report(message)
1177            return
1178
1179        original_current = _GENERATED_PROFILE.discharge_b_one.get("battery_current")
1180        comparison_current = _COMPARISON_PROFILE.discharge_b_one.get("battery_current")
1181
1182        diff = abs(original_current - comparison_current)
1183        average = abs(original_current + comparison_current) / 2
1184        percentage = round((diff / average), 4)
1185        percent_closeness = 0.1
1186
1187        comparison = cmp(percentage * 100, "<=", percent_closeness * 100, "%")
1188
1189        if not math.isclose(original_current, comparison_current, rel_tol=0.1):
1190            logger.write_warning_to_html_report(
1191                f"Battery Regulation Information 1 current percent difference: {comparison}"
1192            )
1193            logger.write_failure_to_html_report("Current was not within expected range of 10% during discharge")
1194            pytest.fail("Current was not within expected range of 10% during charge")
1195
1196        logger.write_result_to_html_report(f"Battery regulation Information 1 current percent difference: {comparison}")
1197        logger.write_result_to_html_report("Current was within expected range of 10% during discharge")
1198
1199    @staticmethod
1200    def get_battery_regulation_one() -> str | dict[str, int | Any]:
1201        """Obtain the Battery Regulation One information"""
1202        battery_regulation_one_request = CANFrame(pgn=PGN["RQST"], data=[0xFF03])
1203        logger.write_result_to_html_report("<span style='font-weight: bold'>Battery Regulation Information 1</span>")
1204
1205        with CANBus(BATTERY_CHANNEL) as bus:
1206            battery_regulation_one: str | dict[str, int | Any]
1207            if battery_frame := bus.process_call(battery_regulation_one_request):
1208                if battery_frame.pgn.id != PGN["PropB_03", [32]].id:
1209                    message = f"Expected {PGN['PropB_03', [32]].id} 0xFF03, but received PGN {battery_frame.pgn.id}"
1210                    logger.write_warning_to_html_report(message)
1211                    battery_regulation_one = BadStates.WRONG_PGN.name
1212                else:
1213                    battery_regulation_one = {
1214                        "battery_voltage": battery_frame.data[0],
1215                        "open_circuit_voltage": battery_frame.data[1],
1216                        "battery_current": battery_frame.data[2],
1217                        "maximum_charge_current": battery_frame.data[3],
1218                    }
1219
1220                    for spn, elem in zip(PGN["PropB_03"].data_field, battery_frame.data):
1221
1222                        if spn.name == "Reserved":
1223                            continue
1224
1225                        description = f"({desc})" if (desc := spn.data_type(elem)) else ""
1226                        logger.write_result_to_html_report(f"{spn.name}: {elem} {description}")
1227
1228                        if spn.name == "Battery Current":
1229                            if -0.1 <= elem <= 0.1:
1230                                message = (
1231                                    "Current was not detected. The power supply leads may not be fully "
1232                                    "connected, or the fuse has blown"
1233                                )
1234                                logger.write_warning_to_html_report(message)
1235                                return "No Current"
1236
1237            else:
1238                message = f"Did not receive response for PGN {PGN['PropB_03', [32]].id} (Battery Regulation One)"
1239                logger.write_warning_to_html_report(message)
1240                battery_regulation_one = BadStates.NO_RESPONSE.name
1241
1242            return battery_regulation_one
1243
1244    @staticmethod
1245    def get_battery_regulation_two() -> str | dict[str, int | Any]:
1246        """Obtain the Battery Regulation Two information"""
1247        battery_regulation_two_request = CANFrame(pgn=PGN["RQST"], data=[0xFF04])
1248        logger.write_result_to_html_report("<span style='font-weight: bold'>Battery Regulation Information 2</span>")
1249
1250        with CANBus(BATTERY_CHANNEL) as bus:
1251            battery_regulation_two: str | dict[str, int | Any]
1252            if battery_frame_two := bus.process_call(battery_regulation_two_request):
1253                if battery_frame_two.pgn.id != PGN["PropB_04", [32]].id:
1254                    message = f"Expected PGN {PGN['PropB_04', [32]].id}, but received PGN {battery_frame_two.pgn.id}"
1255                    logger.write_warning_to_html_report(message)
1256                    battery_regulation_two = BadStates.NO_RESPONSE.name
1257                else:
1258                    battery_regulation_two = {
1259                        "contractor_state": battery_frame_two.data[0],
1260                        "charge_capability_state": battery_frame_two.data[1],
1261                        "bus_voltage_request": battery_frame_two.data[3],
1262                        "transportability_soc": battery_frame_two.data[4],
1263                    }
1264
1265                    for spn, elem in zip(PGN["PropB_04"].data_field, battery_frame_two.data):
1266
1267                        if spn.name == "Reserved":
1268                            continue
1269
1270                        description = f"({desc})" if (desc := spn.data_type(elem)) else ""
1271                        logger.write_result_to_html_report(f"{spn.name}: {elem} {description}")
1272
1273            else:
1274                message = f"Did not receive response for PGN {PGN['PropB_04', [32]].id} (Battery Regulation Two)"
1275                logger.write_warning_to_html_report(message)
1276                battery_regulation_two = BadStates.NO_RESPONSE.name
1277
1278            return battery_regulation_two
1279
1280
1281class TestConfigurationStateMessage:
1282    """Confirms Configuration State Message matches profile after factory reset"""
1283
1284    @pytest.mark.profile
1285    def test_read(self) -> None:
1286        """
1287        | Description          | Checks if Configuration State Message matches profile            |
1288        | :------------------- | :--------------------------------------------------------------- |
1289        | GitHub Issue         | turnaroundfactor/BMS-HW-Test#393                                 |
1290        | Instructions         | 1. Request Configuration State                              </br>\
1291                                 2. Check if Configuration State matches default values      </br>\
1292                                 3. Log results                                                   |
1293        | Estimated Duration   | 21 seconds                                                       |
1294        """
1295
1296        configuration_state_request = CANFrame(pgn=PGN["Request"], data=[PGN["Configuration State Message 1"].id])
1297        with CANBus(BATTERY_CHANNEL) as bus:
1298            if configuration_frame := bus.process_call(configuration_state_request):
1299                if configuration_frame.pgn.id != PGN["Configuration State Message 1", [32]].id:
1300                    message = (
1301                        f"Received PGN {configuration_frame.pgn.id}, not PGN "
1302                        f"{PGN['Configuration State Message 1', [32]].id} "
1303                    )
1304                    logger.write_failure_to_html_report(message)
1305                    _GENERATED_PROFILE.configuration_state_message = BadStates.WRONG_PGN.name
1306                    _GENERATED_PROFILE.configuration_state_specification = "invalid"
1307                    return
1308
1309                data = configuration_frame.data
1310                pgn_data_field = PGN["Configuration State Message 1"].data_field
1311
1312                if len(data) != len(pgn_data_field):
1313                    message = (
1314                        f"Expected {len(pgn_data_field)} data fields, "
1315                        f"got {len(data)}. Data is missing from response"
1316                    )
1317                    logger.write_warning_to_html_report(message)
1318                    _GENERATED_PROFILE.configuration_state_message = BadStates.WRONG_PACKETS.name
1319                    _GENERATED_PROFILE.configuration_state_specification = "invalid"
1320                    return
1321
1322                logger.write_result_to_html_report("<span style='font-weight: bold'>Configuration State Message</span>")
1323
1324                for spn, elem in zip(pgn_data_field, data):
1325                    if spn.name == "Reserved":
1326                        continue
1327
1328                    if not 0 <= elem <= 3:
1329                        message = (
1330                            f"{spn.name}: {cmp(elem, '>=', 0, f'({spn.data_type(elem)})')} and "
1331                            f"{cmp(elem, '<=', 3, f'({spn.data_type(elem)})')}"
1332                        )
1333                        logger.write_warning_to_html_report(message)
1334                        _GENERATED_PROFILE.configuration_state_specification = "invalid"
1335                        continue
1336
1337                    description = f"({desc})" if (desc := spn.data_type(elem)) else ""
1338                    logger.write_result_to_html_report(f"{spn.name}: {elem} {description}")
1339
1340                    if spn.name in ("Battery Battle-override State", "Standby State", "Configure VPMS Function State"):
1341                        message = (
1342                            f"{spn.name}: {cmp(elem, '==', 0, f'({spn.data_type(elem)})', f'({spn.data_type(0)})')}"
1343                        )
1344                        if elem != 0:
1345                            _GENERATED_PROFILE.configuration_state_specification = "invalid"
1346                            logger.write_warning_to_html_report(message)
1347
1348                    if spn.name in ("Automated Heater Function State", "Contactor(s) Control State"):
1349                        message = (
1350                            f"{spn.name}: {cmp(elem, '==', 1, f'({spn.data_type(elem)})', f'({spn.data_type(1)})')}"
1351                        )
1352                        if elem != 1:
1353                            _GENERATED_PROFILE.configuration_state_specification = "invalid"
1354                            logger.write_warning_to_html_report(message)
1355
1356                configuration_state_message = {
1357                    "dormant_1_state": data[0],
1358                    "dormant_2_state": data[1],
1359                    "master_power_switch": data[2],
1360                    "configuration_pin_1_state": data[3],
1361                    "configuration_pin_2_state": data[4],
1362                    "configuration_pin_3_state": data[5],
1363                    "configuration_pin_4_state": data[6],
1364                    "configuration_pin_5_state": data[7],
1365                    "configuration_pin_6_state": data[8],
1366                    "virtual_master_power_switch_state": data[9],
1367                    "battery_battle_override_state": data[11],
1368                    "maintenance_state": data[12],
1369                    "automated_heater_function_state": data[13],
1370                    "battery_heater_state": data[14],
1371                    "contractor_control_state": data[15],
1372                    "standby_state": data[16],
1373                    "baud_rate_overwrite_state": data[18],
1374                    "position_identity_overwrite_state": data[19],
1375                    "configure_vpms_function_state": data[20],
1376                    "pulse_power_control_state": data[21],
1377                }
1378
1379                _GENERATED_PROFILE.configuration_state_message = configuration_state_message
1380
1381                if not hasattr(_GENERATED_PROFILE, "configuration_state_specification"):
1382                    _GENERATED_PROFILE.configuration_state_specification = "valid"
1383
1384            else:
1385                message = (
1386                    f"Did not receive a response for PGN {PGN['Configuration State Message 1', [32]].id} "
1387                    f"(Configuration State Message 1)"
1388                )
1389                _GENERATED_PROFILE.configuration_state_message = BadStates.NO_RESPONSE.name
1390                _GENERATED_PROFILE.configuration_state_specification = "invalid"
1391                logger.write_warning_to_html_report(message)
1392
1393    def test_comparison(self) -> None:
1394        """
1395        | Description          | Compare Configuration State Message 1 values                     |
1396        | :------------------- | :--------------------------------------------------------------- |
1397        | GitHub Issue         | turnaroundfactor/BMS-HW-Test#393                                 |
1398        | Instructions         | 1. Check if Configuration State Message 1 values match           |
1399        | Pass / Fail Criteria | Pass if Configuration State Message 1 values match               |
1400        | Estimated Duration   | 1 second                                                         |
1401        """
1402        failed_test = False
1403        if not hasattr(_GENERATED_PROFILE, "configuration_state_message") or not hasattr(
1404            _COMPARISON_PROFILE, "configuration_state_message"
1405        ):
1406            message = "Nothing to compare. The profile test may have failed or been skipped"
1407            logger.write_result_to_html_report(message)
1408            pytest.skip(message)
1409
1410        failed_categories = []
1411
1412        logger.write_result_to_html_report(
1413            "<span style='font-weight: bold'>Configuration State Message Comparisons</span>"
1414        )
1415
1416        if isinstance(_GENERATED_PROFILE.configuration_state_message, str) or isinstance(
1417            _COMPARISON_PROFILE.configuration_state_message, str
1418        ):
1419            if not isinstance(
1420                _GENERATED_PROFILE.configuration_state_message, type(_COMPARISON_PROFILE.configuration_state_message)
1421            ):
1422                message = "Unable to compare profiles, Configuration State Message types did not match"
1423                logger.write_failure_to_html_report(message)
1424                pytest.fail(message)
1425
1426            comparison = cmp(
1427                _GENERATED_PROFILE.configuration_state_message, "==", _COMPARISON_PROFILE.configuration_state_message
1428            )
1429
1430            if _GENERATED_PROFILE.configuration_state_message != _COMPARISON_PROFILE.configuration_state_message:
1431                message = f"Configuration State Message did not match: {comparison}"
1432                logger.write_failure_to_html_report(message)
1433                pytest.fail(message)
1434
1435            message = f"Configuration State Message matched: {comparison}"
1436            logger.write_result_to_html_report(message)
1437        else:
1438
1439            for key, value in _GENERATED_PROFILE.configuration_state_message.items():
1440                key_text = key.replace("_", " ").title()
1441                comparison = cmp(value, "==", _COMPARISON_PROFILE.configuration_state_message[key])
1442                message = f"{key_text}: {comparison}"
1443
1444                if value != _COMPARISON_PROFILE.configuration_state_message[key]:
1445                    logger.write_warning_to_html_report(message)
1446                    failed_categories.append(key_text)
1447                else:
1448                    logger.write_result_to_html_report(message)
1449
1450            fail_length = len(failed_categories)
1451
1452            if fail_length > 0:
1453                categories = "data values" if fail_length > 1 else "data value"
1454                message = f"{fail_length} {categories} failed profile comparisons: {', '.join(failed_categories)}"
1455                logger.write_failure_to_html_report(message)
1456                failed_test = True
1457
1458        if not hasattr(_GENERATED_PROFILE, "configuration_state_specification") or not hasattr(
1459            _COMPARISON_PROFILE, "configuration_state_specification"
1460        ):
1461            logger.write_warning_to_html_report("Missing specification information")
1462        else:
1463            comparison = cmp(
1464                _GENERATED_PROFILE.configuration_state_specification,
1465                "==",
1466                _COMPARISON_PROFILE.configuration_state_specification,
1467            )
1468            if (
1469                _GENERATED_PROFILE.configuration_state_specification
1470                != _COMPARISON_PROFILE.configuration_state_specification
1471            ):
1472                logger.write_failure_to_html_report(f"Specification comparison failed: {comparison}")
1473                failed_test = True
1474            else:
1475                logger.write_result_to_html_report(f"Specification comparison passed: {comparison}")
1476
1477        if failed_test:
1478            message = "Comparisons did not match in profiles"
1479            logger.write_failure_to_html_report(message)
1480            pytest.fail(message)
1481
1482        logger.write_result_to_html_report("Configuration State Message 1 values successfully matched")
1483
1484
1485def na_maintenance_mode(bus: CANBus) -> bool:
1486    """This function will place battery in Maintenance Mode with Reset Value of "3"."""
1487
1488    maintenance_mode = CANFrame(  # Memory access only works in maintenance mode
1489        destination_address=_GENERATED_PROFILE.address,
1490        pgn=PGN["PropA"],
1491        data=[0, 0, 1, 1, 1, 3, 1, 3, 0, -1],
1492    )
1493
1494    bus.send_message(maintenance_mode.message())  # Enter maintenance mode
1495    if response := bus.read_input():
1496        ack_frame = CANFrame.decode(response.arbitration_id, response.data)
1497        if ack_frame.pgn.id != 0xE800 and ack_frame.data[0] != 0:
1498            logger.write_warning_to_html_report("Unable to enter maintenance mode")
1499            return False
1500        return True
1501
1502    message = "Received no maintenance mode response"
1503    logger.write_warning_to_html_report(message)
1504    return False
1505
1506
1507class TestNameManagementMessage:
1508    """This will test the mandatory NAME Management commands"""
1509
1510    @pytest.mark.profile
1511    def test_name_management_command(self) -> None:
1512        """
1513        | Description          | Test Name Management Command                                     |
1514        | :------------------- | :--------------------------------------------------------------- |
1515        | GitHub Issue         | turnaroundfactor/BMS-HW-Test#389                                 |
1516        | Instructions         | 1. Get NAME information from Address Claimed                </br>\
1517                                 2. Change ECU value                                         </br>\
1518                                 3. Test Name Management Command                             </br>\
1519                                 4. Check Values updated                                     </br>\
1520                                 5. Log results                                                   |
1521        | Estimated Duration   | 21 seconds                                                       |
1522        """
1523
1524        old_ecu_value = 0
1525        new_ecu_value = 0
1526        checksum_value = 0
1527        adopt_name_request_data: list[float] = [255, 1, 1, 1, 1, 1, 1, 1, 1, 7, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1]
1528        address_request = CANFrame(pgn=PGN["Request"], data=[PGN["Address Claimed"].id])
1529
1530        with CANBus(BATTERY_CHANNEL) as bus:
1531            entered_maintenance_mode = na_maintenance_mode(bus)
1532            if not entered_maintenance_mode:
1533                _GENERATED_PROFILE.name_management_ecu_value = BadStates.NO_RESPONSE.name
1534                _GENERATED_PROFILE.name_management_ecu_specification = "invalid"
1535
1536            # Name Management Test
1537            if address_claimed := bus.process_call(address_request):
1538                if address_claimed.pgn.id != PGN["Address Claimed", [32]].id:
1539                    Errors.unexpected_packet("Address Claimed", address_claimed)
1540                    message = f"Unexpected packet PGN {address_claimed.pgn.id} was received"
1541                    logger.write_warning_to_html_report(message)
1542                    _GENERATED_PROFILE.name_management_ecu_value = BadStates.WRONG_PACKETS.name
1543                    _GENERATED_PROFILE.name_management_ecu_specification = "invalid"
1544                    return
1545
1546                checksum_value = sum(address_claimed.packed_data) & 0xFF
1547                old_ecu_value = address_claimed.data[2]
1548                if old_ecu_value > 0:
1549                    new_ecu_value = 0
1550                else:
1551                    new_ecu_value = 1
1552            else:
1553                message = f"No response received for PGN {[PGN['Address Claimed', [32]].id]}"
1554                logger.write_warning_to_html_report(message)
1555                _GENERATED_PROFILE.name_management_ecu_value = BadStates.NO_RESPONSE.name
1556                _GENERATED_PROFILE.name_management_ecu_specification = "invalid"
1557                return
1558
1559            if not checksum_value:
1560                message = "Could not condense Name into bits for NAME Management"
1561                logger.write_warning_to_html_report(message)
1562                _GENERATED_PROFILE.name_management_ecu_value = BadStates.INVALID_RESPONSE.name
1563                _GENERATED_PROFILE.name_management_ecu_specification = "invalid"
1564                return
1565
1566            request_data: list[float] = [
1567                checksum_value,
1568                1,
1569                0,
1570                1,
1571                1,
1572                1,
1573                1,
1574                1,
1575                1,
1576                0,
1577                1,
1578                1,
1579                new_ecu_value,
1580                1,
1581                1,
1582                1,
1583                1,
1584                1,
1585                1,
1586                1,
1587            ]
1588            name_management_set_name = CANFrame(
1589                destination_address=_GENERATED_PROFILE.address, pgn=PGN["NAME Management Message"], data=request_data
1590            )
1591
1592            if pending_response := bus.process_call(name_management_set_name):
1593                if pending_response.pgn.id != PGN["NAME Management Message", [32]].id:
1594                    if pending_response.pgn.id == PGN["Acknowledgement", [32]].id:
1595                        message = (
1596                            f"Battery may not support NAME Management, received PGN "
1597                            f"{pending_response.pgn.id} ({spn_types.acknowledgement(pending_response.data[0])})"
1598                        )
1599                        logger.write_warning_to_html_report(message)
1600                        _GENERATED_PROFILE.name_management_ecu_value = BadStates.NACK.name
1601                        _GENERATED_PROFILE.name_management_ecu_specification = "invalid"
1602
1603                    else:
1604                        Errors.unexpected_packet("NAME Management Message", pending_response)
1605                        message = f"Unexpected PGN {pending_response.pgn.id} received"
1606                        logger.write_warning_to_html_report(message)
1607                        _GENERATED_PROFILE.name_management_ecu_value = BadStates.WRONG_PGN.name
1608                        _GENERATED_PROFILE.name_management_ecu_specification = "invalid"
1609
1610                    return
1611
1612                if pending_response.data[0] == 3:
1613                    message = (
1614                        f"Message was unsuccessful, received error: "
1615                        f"{spn_types.name_error_code(pending_response.data[0])}"
1616                    )
1617                    logger.write_warning_to_html_report(message)
1618                    _GENERATED_PROFILE.name_management_ecu_value = BadStates.INVALID_RESPONSE.name
1619                    _GENERATED_PROFILE.name_management_ecu_specification = "invalid"
1620                    return
1621
1622                if pending_response.data[12] != new_ecu_value:
1623                    message = f"ECU Value was not changed from {old_ecu_value} to {new_ecu_value}"
1624                    logger.write_warning_to_html_report(message)
1625                    _GENERATED_PROFILE.name_management_ecu_value = BadStates.INVALID_RESPONSE.name
1626                    _GENERATED_PROFILE.name_management_ecu_specification = "invalid"
1627                    return
1628            else:
1629                message = (
1630                    f"No response received for PGN {PGN['NAME Management Message', [32]].id} (NAME Management Message)"
1631                )
1632                logger.write_warning_to_html_report(message)
1633                _GENERATED_PROFILE.name_management_ecu_value = BadStates.NO_RESPONSE.name
1634                _GENERATED_PROFILE.name_management_ecu_specification = "invalid"
1635                return
1636
1637            adopt_name_request = CANFrame(
1638                destination_address=_GENERATED_PROFILE.address,
1639                pgn=PGN["NAME Management Message"],
1640                data=adopt_name_request_data,
1641            )
1642
1643            bus.send_message(adopt_name_request.message())
1644
1645            current_name_request_data: list[float] = [255, 1, 1, 1, 1, 1, 1, 1, 1, 6, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1]
1646            current_name_request = CANFrame(
1647                destination_address=_GENERATED_PROFILE.address,
1648                pgn=PGN["NAME Management Message"],
1649                data=current_name_request_data,
1650            )
1651
1652            if name_management_response := bus.process_call(current_name_request):
1653                if name_management_response.data[12] != new_ecu_value:
1654                    message = (
1655                        f"Name's ECU Instance was not updated after Name Management Changes, "
1656                        f"{cmp(name_management_response.data[12], '==', new_ecu_value)}"
1657                    )
1658                    logger.write_warning_to_html_report(message)
1659                    _GENERATED_PROFILE.name_management_ecu_value = BadStates.INVALID_RESPONSE.name
1660                    _GENERATED_PROFILE.name_management_ecu_specification = "invalid"
1661                    return
1662            else:
1663                message = (
1664                    f"No response received for PGN {PGN['NAME Management Message', [32]].id} (NAME Management Message)"
1665                )
1666                logger.write_warning_to_html_report(message)
1667                _GENERATED_PROFILE.name_management_ecu_value = BadStates.NO_RESPONSE.name
1668                _GENERATED_PROFILE.name_management_ecu_specification = "invalid"
1669                return
1670
1671            if new_address_claimed := bus.process_call(address_request):
1672                if new_address_claimed.data[2] != new_ecu_value + 1:
1673                    message = (
1674                        f"Address Claimed was not updated after Name Management Changes. "
1675                        f"{cmp(new_address_claimed.data[2], '==', new_ecu_value + 1)}"
1676                    )
1677                    logger.write_warning_to_html_report(message)
1678                    _GENERATED_PROFILE.name_management_ecu_value = BadStates.INVALID_RESPONSE.name
1679                    _GENERATED_PROFILE.name_management_ecu_specification = "invalid"
1680                    return
1681
1682            _GENERATED_PROFILE.name_management_ecu_value = address_claimed.data[2]
1683            logger.write_result_to_html_report(
1684                f"Name Management Command was successful. "
1685                f"ECU Instance was changed from {old_ecu_value} to {new_ecu_value}"
1686            )
1687            _GENERATED_PROFILE.name_management_ecu_specification = "valid"
1688
1689    @pytest.mark.profile
1690    def test_wrong_name_management_data(self):
1691        """
1692        | Description          | Test Incorrect Data for NAME Management Command                  |
1693        | :------------------- | :--------------------------------------------------------------- |
1694        | GitHub Issue         | turnaroundfactor/BMS-HW-Test#389                                 |
1695        | Instructions         | 1. Provide wrong Checksum value for Name Command            </br>\
1696                                 2. Check receive Checksum error code as response            </br>\
1697                                 3. Log results                                                   |
1698        | Estimated Duration   | 1 second                                                         |
1699        """
1700        wrong_checksum = 0
1701        new_ecu_value = 0
1702
1703        current_name_request_data = [255, 1, 1, 1, 1, 1, 1, 1, 1, 6, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1]
1704        current_name_request = CANFrame(
1705            destination_address=_GENERATED_PROFILE.address,
1706            pgn=PGN["NAME Management Message"],
1707            data=current_name_request_data,
1708        )
1709
1710        with CANBus(BATTERY_CHANNEL) as bus:
1711            # Test Checksum Error -- Should return Error Code: 3
1712            in_maintenance_mode = na_maintenance_mode(bus)
1713
1714            if not in_maintenance_mode:
1715                _GENERATED_PROFILE.wrong_name_data = BadStates.NO_RESPONSE.name
1716                _GENERATED_PROFILE.wrong_name_specification = "invalid"
1717
1718            if current_name := bus.process_call(current_name_request):
1719                if current_name.pgn.id != PGN["NAME Management Message", [32]].id:
1720                    if current_name.pgn.id == PGN["Acknowledgement", [32]].id:
1721                        message = (
1722                            f"Battery may not support NAME Management, received PGN "
1723                            f"{current_name.pgn.id} ({spn_types.acknowledgement(current_name.data[0])})"
1724                        )
1725                        logger.write_warning_to_html_report(message)
1726                        _GENERATED_PROFILE.wrong_name_data = BadStates.NACK.name
1727                        _GENERATED_PROFILE.wrong_name_specification = "invalid"
1728                        return
1729                    Errors.unexpected_packet("NAME Management Message", current_name)
1730                    message = f"Unexpected packet was received: PGN {current_name.pgn.id}"
1731                    logger.write_warning_to_html_report(message)
1732                    _GENERATED_PROFILE.wrong_name_data = BadStates.WRONG_PACKETS.name
1733                    _GENERATED_PROFILE.wrong_name_specification = "invalid"
1734                    return
1735
1736                wrong_checksum = sum(current_name.packed_data) & 0xFF
1737                old_ecu_value = current_name.data[12]
1738                if old_ecu_value > 0:
1739                    new_ecu_value = 0
1740                else:
1741                    new_ecu_value = 1
1742            else:
1743                message = (
1744                    f"No response received for PGN {PGN['NAME Management Message', [32]].id} (NAME Management Message)"
1745                )
1746                logger.write_warning_to_html_report(message)
1747                _GENERATED_PROFILE.wrong_name_data = BadStates.NO_RESPONSE.name
1748                _GENERATED_PROFILE.wrong_name_specification = "invalid"
1749                return
1750
1751            request_data = [wrong_checksum, 1, 0, 1, 1, 1, 1, 1, 1, 0, 1, 1, new_ecu_value, 1, 1, 1, 1, 1, 1, 1]
1752            name_management_set_name = CANFrame(
1753                destination_address=_GENERATED_PROFILE.address, pgn=PGN["NAME Management Message"], data=request_data
1754            )
1755
1756            if pending_response := bus.process_call(name_management_set_name):
1757                if pending_response.pgn.id != PGN["NAME Management Message", [32]].id:
1758                    if pending_response.pgn.id == PGN["Acknowledgement", [32]].id:
1759                        message = (
1760                            f"Battery may not support NAME Management, received PGN {pending_response.pgn.id} "
1761                            f"({spn_types.acknowledgement(pending_response.data[0])})"
1762                        )
1763                        logger.write_warning_to_html_report(message)
1764                        _GENERATED_PROFILE.wrong_name_data = BadStates.NACK.name
1765                        _GENERATED_PROFILE.wrong_name_specification = "invalid"
1766                        return
1767
1768                    Errors.unexpected_packet("NAME Management Message", pending_response)
1769                    message = f"Unexpected packet was received: {pending_response.pgn.id}"
1770                    logger.write_warning_to_html_report(message)
1771                    _GENERATED_PROFILE.wrong_name_data = BadStates.WRONG_PGN.name
1772                    _GENERATED_PROFILE.wrong_name_specification = "invalid"
1773                    return
1774
1775                if pending_response.data[0] != 3:
1776                    message = cmp(
1777                        pending_response.data[0],
1778                        "==",
1779                        3,
1780                        f"({spn_types.name_error_code(pending_response.data[0])})",
1781                        f"({spn_types.name_error_code(3)})",
1782                    )
1783                    logger.write_warning_to_html_report(
1784                        f"PGN {PGN['NAME Management Message', [32]].id} (NAME Management Message) "
1785                        f"updated NAME Management with incorrect checksum value"
1786                    )
1787                    logger.write_warning_to_html_report(message)
1788                    _GENERATED_PROFILE.wrong_name_data = "Incorrect checksum value"
1789                    _GENERATED_PROFILE.wrong_name_specification = "invalid"
1790                    return
1791            else:
1792                message = (
1793                    f"No response received for PGN {PGN['NAME Management Message', [32]].id} (NAME Management Message)"
1794                )
1795                logger.write_warning_to_html_report(message)
1796                _GENERATED_PROFILE.wrong_name_data = BadStates.NO_RESPONSE.name
1797                _GENERATED_PROFILE.wrong_name_specification = "invalid"
1798                return
1799
1800            logger.write_result_to_html_report(
1801                "NAME Management successfully did not process request with incorrect checksum value"
1802            )
1803            _GENERATED_PROFILE.wrong_name_data = "Passed"
1804            _GENERATED_PROFILE.wrong_name_specification = "valid"
1805
1806    def test_name_comparison(self) -> None:
1807        """
1808        | Description          | Compare if Name Management ECU values were changed               |
1809        | :------------------- | :--------------------------------------------------------------- |
1810        | GitHub Issue         | turnaroundfactor/BMS-HW-Test#393                                 |
1811        | Instructions         | 1. Check if Name Management ECU values matched in profiles       |
1812        | Pass / Fail Criteria | Pass if Name Management ECU values match                         |
1813        | Estimated Duration   | 1 second                                                         |
1814        """
1815        failed_test = False
1816        if not hasattr(_GENERATED_PROFILE, "name_management_ecu_value") or not hasattr(
1817            _COMPARISON_PROFILE, "name_management_ecu_value"
1818        ):
1819            message = "Nothing to compare. The profile test may have failed or been skipped"
1820            logger.write_result_to_html_report(message)
1821            pytest.skip(message)
1822
1823        comparison = cmp(
1824            _GENERATED_PROFILE.name_management_ecu_value, "==", _COMPARISON_PROFILE.name_management_ecu_value
1825        )
1826
1827        if _GENERATED_PROFILE.name_management_ecu_value != _COMPARISON_PROFILE.name_management_ecu_value:
1828            comparison = cmp(
1829                _GENERATED_PROFILE.name_management_ecu_value, "==", _COMPARISON_PROFILE.name_management_ecu_value
1830            )
1831            message = f"Name Management ECU values did not match: {comparison}"
1832            logger.write_failure_to_html_report(message)
1833            failed_test = True
1834
1835        logger.write_result_to_html_report(f"Name Management ECU values matched: {comparison}")
1836
1837        if not hasattr(_GENERATED_PROFILE, "name_management_ecu_specification") or not hasattr(
1838            _COMPARISON_PROFILE, "name_management_ecu_specification"
1839        ):
1840            logger.write_warning_to_html_report("Missing specification information")
1841        else:
1842            comparison = cmp(
1843                _GENERATED_PROFILE.name_management_ecu_specification,
1844                "==",
1845                _COMPARISON_PROFILE.name_management_ecu_specification,
1846            )
1847            if (
1848                _GENERATED_PROFILE.name_management_ecu_specification
1849                != _COMPARISON_PROFILE.name_management_ecu_specification
1850            ):
1851                logger.write_failure_to_html_report(f"Specification comparison failed: {comparison}")
1852                failed_test = True
1853            else:
1854                logger.write_result_to_html_report(f"Specification comparison passed: {comparison}")
1855
1856        if failed_test:
1857            message = "Comparisons did not match in profiles"
1858            logger.write_failure_to_html_report(message)
1859            pytest.fail(message)
1860
1861    def test_wrong_name_comparison(self) -> None:
1862        """
1863        | Description          | Compare if Name Management values were changed with wrong data   |
1864        | :------------------- | :--------------------------------------------------------------- |
1865        | GitHub Issue         | turnaroundfactor/BMS-HW-Test#393                                 |
1866        | Instructions         | 1. Check if Name Management ECU values matched in profiles       |
1867        | Pass / Fail Criteria | Pass if Name Management ECU values match                         |
1868        | Estimated Duration   | 1 second                                                         |
1869        """
1870        failed_test = False
1871        if not hasattr(_GENERATED_PROFILE, "wrong_name_data") or not hasattr(_COMPARISON_PROFILE, "wrong_name_data"):
1872            message = "Nothing to compare. The profile test may have failed or been skipped"
1873            logger.write_result_to_html_report(message)
1874            pytest.skip(message)
1875
1876        if _GENERATED_PROFILE.wrong_name_data != _COMPARISON_PROFILE.wrong_name_data:
1877            comparison = f"{_GENERATED_PROFILE.wrong_name_data}{ _COMPARISON_PROFILE.wrong_name_data}"
1878            message = f"Name Management values did not match from Wrong Name Management test: {comparison}"
1879            logger.write_failure_to_html_report(message)
1880            failed_test = True
1881        else:
1882            logger.write_result_to_html_report(
1883                f"Name Management values had same results from Wrong Name Management test: "
1884                f"{_GENERATED_PROFILE.wrong_name_data}"
1885            )
1886
1887        if not hasattr(_GENERATED_PROFILE, "wrong_name_specification") or not hasattr(
1888            _COMPARISON_PROFILE, "wrong_name_specification"
1889        ):
1890            logger.write_warning_to_html_report("Missing specification information")
1891        else:
1892            comparison = cmp(
1893                _GENERATED_PROFILE.wrong_name_specification,
1894                "==",
1895                _COMPARISON_PROFILE.wrong_name_specification,
1896            )
1897            if _GENERATED_PROFILE.wrong_name_specification != _COMPARISON_PROFILE.wrong_name_specification:
1898                logger.write_failure_to_html_report(f"Specification comparison failed: {comparison}")
1899                failed_test = True
1900            else:
1901                logger.write_result_to_html_report(f"Specification comparison passed: {comparison}")
1902
1903        if failed_test:
1904            message = "Comparisons did not match in profiles"
1905            logger.write_failure_to_html_report(message)
1906            pytest.fail(message)
1907
1908
1909class TestActiveDiagnosticTroubleCodes:
1910    """Test that Active Diagnostic Trouble Codes are J1939 Compliant"""
1911
1912    @pytest.mark.profile
1913    def test_profile(self) -> None:
1914        """
1915        | Description          | Test Active Diagnostic Trouble Codes (DM1)                       |
1916        | :------------------- | :--------------------------------------------------------------- |
1917        | GitHub Issue         | turnaroundfactor/BMS-HW-Test#389                                 |
1918        | Instructions         | 1. Request Active Diagnostic Trouble Codes                  </br>\
1919                                 2. Test Command Addresses command                           </br>\
1920                                 3. Confirm Address was updated                              </br>\
1921                                 4. Log results                                                   |
1922        | Estimated Duration   | 21 seconds                                                       |
1923        """
1924        dm1_request = CANFrame(pgn=PGN["Request"], data=[PGN["Active Diagnostic Trouble Codes", [32]].id])
1925        with CANBus(BATTERY_CHANNEL) as bus:
1926            if dm1_frame := bus.process_call(dm1_request):
1927                if dm1_frame.pgn.id != PGN["Active Diagnostic Trouble Codes", [32]].id:
1928                    Errors.unexpected_packet("Active Diagnostic Trouble Codes", dm1_frame)
1929                    message = f"Unexpected data packet PGN {dm1_frame.pgn.id} was received"
1930                    logger.write_warning_to_html_report(message)
1931                    _GENERATED_PROFILE.active_diagnostic_trouble_codes = BadStates.WRONG_PGN.name
1932                    _GENERATED_PROFILE.active_diagnostic_specification = "invalid"
1933                    return
1934
1935                logger.write_result_to_html_report(
1936                    "<span style='font-weight: bold'>Active Diagnostic Trouble Codes</span>"
1937                )
1938
1939                dm1_data = dm1_frame.data
1940                pgn_data_field = PGN["Active Diagnostic Trouble Codes"].data_field
1941
1942                for spn, elem in zip(pgn_data_field, dm1_data):
1943                    description = f"({desc})" if (desc := spn.data_type(elem)) else ""
1944                    logger.write_result_to_html_report(f"{spn.name}: {elem} {description}")
1945                    high_value = 1
1946
1947                    if spn.name in (
1948                        "Protect Lamp",
1949                        "Amber Warning Lamp",
1950                        "Red Stop Lamp",
1951                        "Malfunction Indicator Lamp",
1952                        "DTC1.SPN_Conversion_Method",
1953                    ):
1954                        high_value = 1
1955
1956                    if spn.name in (
1957                        "Flash Protect Lamp",
1958                        "Flash Amber Warning Lamp",
1959                        "Flash Red Stop Lamp",
1960                        "Flash Malfunction Indicator Lamp",
1961                    ):
1962                        high_value = 3
1963
1964                    if spn.name == "DTC1.Suspect_Parameter_Number":
1965                        high_value = 524287
1966
1967                    if spn.name == "DTC1.Failure_Mode_Identifier":
1968                        high_value = 31
1969
1970                    if spn.name == "DTC1.Occurrence_Count":
1971                        high_value = 126
1972
1973                    if not 0 <= elem <= high_value:
1974                        logger.write_warning_to_html_report(
1975                            f"{spn.name}: {cmp(elem, '>=', 0, description)} and "
1976                            f"{cmp(elem, '<=', high_value, description)}"
1977                        )
1978                        _GENERATED_PROFILE.active_diagnostic_specification = "invalid"
1979
1980                _GENERATED_PROFILE.active_diagnostic_trouble_codes = {
1981                    "protect_lamp": dm1_data[0],
1982                    "amber_warning_lamp": dm1_data[1],
1983                    "red_stop_lamp": dm1_data[2],
1984                    "malfunction_indicator_lamp": dm1_data[3],
1985                    "flash_protect_lamp": dm1_data[4],
1986                    "flash_amber_warning_lamp": dm1_data[5],
1987                    "flash_red_stop_lamp": dm1_data[6],
1988                    "flash_malfunction_indicator_lamp": dm1_data[7],
1989                    "dtc1_suspect_parameter_number": dm1_data[8],
1990                    "dtc1_failure_mode_identifier": dm1_data[9],
1991                    "dtc1_occurrence_count": dm1_data[10],
1992                    "dtc1_spn_conversion_method": dm1_data[11],
1993                }
1994
1995                if not hasattr(_GENERATED_PROFILE, "active_diagnostic_specification"):
1996                    _GENERATED_PROFILE.active_diagnostic_specification = "valid"
1997
1998            else:
1999                message = (
2000                    f"No response was received for mandatory command: "
2001                    f"PGN {PGN['Active Diagnostic Trouble Codes', [32]].id} (Active Diagnostic Trouble Codes)"
2002                )
2003                logger.write_warning_to_html_report(message)
2004                _GENERATED_PROFILE.active_diagnostic_trouble_codes = BadStates.NO_RESPONSE.name
2005                _GENERATED_PROFILE.active_diagnostic_specification = "invalid"
2006
2007    def test_comparison(self) -> None:
2008        """
2009        | Description          | Compare if Active Diagnostic Trouble Codes matched               |
2010        | :------------------- | :--------------------------------------------------------------- |
2011        | GitHub Issue         | turnaroundfactor/BMS-HW-Test#393                                 |
2012        | Instructions         | 1. Check if Active Diagnostic Trouble Codes matched in profiles  |
2013        | Pass / Fail Criteria | Pass if Active Diagnostic Trouble Codes match                    |
2014        | Estimated Duration   | 1 second                                                         |
2015        """
2016        failed_test = False
2017        if not hasattr(_GENERATED_PROFILE, "active_diagnostic_trouble_codes") or not hasattr(
2018            _COMPARISON_PROFILE, "active_diagnostic_trouble_codes"
2019        ):
2020            message = "Nothing to compare. The profile test may have failed or been skipped"
2021            logger.write_result_to_html_report(message)
2022            pytest.skip(message)
2023
2024        if isinstance(_GENERATED_PROFILE.active_diagnostic_trouble_codes, str) or isinstance(
2025            _COMPARISON_PROFILE.active_diagnostic_trouble_codes, str
2026        ):
2027            if not isinstance(
2028                _GENERATED_PROFILE.active_diagnostic_trouble_codes,
2029                type(_COMPARISON_PROFILE.active_diagnostic_trouble_codes),
2030            ):
2031                message = "Unable to compare profiles, Active Diagnostic Trouble Codes types did not match"
2032                logger.write_failure_to_html_report(message)
2033                pytest.fail(message)
2034
2035            comparison = cmp(
2036                _GENERATED_PROFILE.active_diagnostic_trouble_codes,
2037                "==",
2038                _COMPARISON_PROFILE.active_diagnostic_trouble_codes,
2039            )
2040
2041            if (
2042                _GENERATED_PROFILE.active_diagnostic_trouble_codes
2043                != _COMPARISON_PROFILE.active_diagnostic_trouble_codes
2044            ):
2045                message = f"Active Diagnostic Trouble Codes did not match: {comparison}"
2046                logger.write_failure_to_html_report(message)
2047                pytest.fail(message)
2048
2049            message = f"Active Diagnostic Trouble Codes matched: {comparison}"
2050            logger.write_result_to_html_report(message)
2051        else:
2052            failed_categories = []
2053            for key, value in _GENERATED_PROFILE.active_diagnostic_trouble_codes.items():
2054                key_text = key.replace("_", " ").title()
2055
2056                if key == "dtc1_suspect_parameter_number":
2057                    key_text = "DTC1.Suspect_Parameter_Number"
2058
2059                if key == "dtc1_failure_mode_identifier":
2060                    key_text = "DTC1.Failure_Mode_Identifier"
2061
2062                if key == "dtc1_occurrence_count":
2063                    key_text = "DTC1.Occurrence_Count"
2064
2065                if key == "dtc1_spn_conversion_method":
2066                    key_text = "DTC1.SPN_Conversion_Method"
2067
2068                comparison = cmp(value, "==", _COMPARISON_PROFILE.active_diagnostic_trouble_codes[key])
2069                message = f"{key_text}: {comparison}"
2070
2071                if value != _COMPARISON_PROFILE.active_diagnostic_trouble_codes[key]:
2072                    logger.write_warning_to_html_report(message)
2073                    failed_categories.append(key_text)
2074                else:
2075                    logger.write_result_to_html_report(message)
2076
2077            fail_length = len(failed_categories)
2078
2079            if fail_length > 0:
2080                categories = "data values" if fail_length > 1 else "data value"
2081                message = f"{fail_length} {categories} failed profile comparisons: {', '.join(failed_categories)}"
2082                logger.write_failure_to_html_report(message)
2083                failed_test = True
2084            else:
2085                logger.write_result_to_html_report("Active Diagnostic Trouble Code values between profiles matched")
2086
2087        if not hasattr(_GENERATED_PROFILE, "active_diagnostic_specification") or not hasattr(
2088            _COMPARISON_PROFILE, "active_diagnostic_specification"
2089        ):
2090            logger.write_warning_to_html_report("Missing specification information")
2091        else:
2092            comparison = cmp(
2093                _GENERATED_PROFILE.active_diagnostic_specification,
2094                "==",
2095                _COMPARISON_PROFILE.active_diagnostic_specification,
2096            )
2097            if (
2098                _GENERATED_PROFILE.active_diagnostic_specification
2099                != _COMPARISON_PROFILE.active_diagnostic_specification
2100            ):
2101                logger.write_failure_to_html_report(f"Specification comparison failed: {comparison}")
2102                failed_test = True
2103            else:
2104                logger.write_result_to_html_report(f"Specification comparison passed: {comparison}")
2105
2106        if failed_test:
2107            message = "Comparisons did not match in profiles"
2108            logger.write_failure_to_html_report(message)
2109            pytest.fail(message)
2110
2111
2112#   TODO(DF): Fix Stop Start Broadcast Test when battery is able to broadcast, test currently blocked
2113# class TestStopStartBroadcast:
2114#     """Test that Stop/Start Broadcast is J1939 Compliant"""
2115#
2116#     @pytest.mark.profile
2117#     def test_profile(self) -> None:
2118#         """
2119#         | Description          | Test that Stop/Start Broadcast (DM13)                            |
2120#         | :------------------- | :--------------------------------------------------------------- |
2121#         | GitHub Issue         | turnaroundfactor/BMS-HW-Test#389                                 |
2122#         | Instructions         | 1. Create Start Broadcast request                           </br>\
2123#                                  2. Check broadcast began                                    </br>\
2124#                                  3. Create Stop Broadcast request                            </br>\
2125#                                  4. Check broadcast stopped                                  </br>\
2126#                                  5. Create Suspend Broadcast request                         </br>\
2127#                                  6. Check broadcast suspended                                </br>\
2128#                                  7. Log results                                                   |
2129#         | Estimated Duration   | 1 second                                                         |
2130#         """
2131#
2132#         with CANBus(BATTERY_CHANNEL) as bus:
2133#             na_maintenance_mode(bus)
2134#               TODO(DF): Determine order of data (either SAFT or order in j1939da.py)
2135#             broadcast_message_data = {
2136#                 "current_data_link": 1,
2137#                 "sae_j1587": 3,
2138#                 "sae_j1922": 3,
2139#                 "sae_j1939_network_1": 1,
2140#                 "sae_j1939_network_2": 3,
2141#                 "iso_9141": 3,
2142#                 "sae_j1850": 3,
2143#                 "other_manufacturer_specified_port": 3,
2144#                 "sae_j1939_network #3": 3,
2145#                 "proprietary_network_1": 3,
2146#                 "proprietary_network_2": 3,
2147#                 "sae_j1939_network_4": 3,
2148#                 "hold_signal": 15,
2149#                 "suspend_signal": 255,
2150#                 "suspend_duration": 0,
2151#                 "sae_j1939_network_5": 3,
2152#                 "sae_j1939_network_6": 3,
2153#                 "sae_j1939_network_7": 3,
2154#                 "sae_j1939_network_8": 3,
2155#                 "reserved": 2,
2156#                 "sae_j1939_network_9": 3,
2157#                 "sae_j1939_network_10": 3,
2158#                 "sae_j1939_network_11": 3,
2159#             }
2160#             # Order from SAFT Documentation
2161#             # broadcast_message_data = {
2162#             #     "sae_j1939_network_1": 1,
2163#             #     "sae_j1922": 3,
2164#             #     "sae_j1587": 3,
2165#             #     "current_data_link": 1,
2166#             #     "other_manufacturer_specified_port": 3,
2167#             #     "sae_j1850": 3,
2168#             #     "iso_9141": 3,
2169#             #     "sae_j1939_network_2": 3,
2170#             #     "sae_j1939_network_4": 3,
2171#             #     "proprietary_network_2": 3,
2172#             #     "proprietary_network_1": 3,
2173#             #     "sae_j1939_network #3": 3,
2174#             #     "suspend_signal": 255,
2175#             #     "hold_signal": 15,
2176#             #     "suspend_duration": 0,
2177#             #     "sae_j1939_network_8": 3,
2178#             #     "sae_j1939_network_7": 3,
2179#             #     "sae_j1939_network_6": 3,
2180#             #     "sae_j1939_network_5": 3,
2181#             #     "sae_j1939_network_11": 3,
2182#             #     "sae_j1939_network_10": 3,
2183#             #     "sae_j1939_network_9": 3,
2184#             #     "reserved": 2,
2185#             # }
2186#
2187#             dm1_start_request_data = list(broadcast_message_data.values())
2188#
2189#             # Start Broadcast
2190#             dm1_request = CANFrame(pgn=PGN["Stop Start Broadcast"], data=dm1_start_request_data)
2191#             bus.send_message(dm1_request.message())
2192#
2193#             # Check broadcast
2194#             if mystery_response := bus.read_input():
2195#                 logger.write_info_to_report("Broadcast was successfully started")
2196#                 logger.write_info_to_report(f"First broadcast received: {mystery_response}")
2197#             else:
2198#                 message = "Broadcast did not start running Stop Start Broadcast command"
2199#                 logger.write_warning_to_report(message)
2200#                 pytest.fail(message)
2201#
2202#             # Stop Broadcast
2203#             broadcast_message_data["sae_j1939_network_1"] = 3
2204#             broadcast_message_data["current_data_link"] = 3
2205#             broadcast_message_data["hold_signal"] = 1
2206#             dm1_stop_request_data = list(broadcast_message_data.values())
2207#
2208#             dm1_request = CANFrame(pgn=PGN["Stop Start Broadcast"], data=dm1_stop_request_data)
2209#             bus.send_message(dm1_request.message())
2210#
2211#             if mystery_response := bus.read_input():
2212#                 message = "Broadcast did not stop running after Stop Start Broadcast command"
2213#                 logger.write_warning_to_report(message)
2214#                 logger.write_info_to_report(f"Broadcast received: {mystery_response}")
2215#
2216#             else:
2217#                 message = "Broadcast was succesfully stopped after Stop Start Broadcast command"
2218#                 logger.write_info_to_report(message)
2219#
2220#             broadcast_message_data["suspend_signal"] = 0
2221#             dm1_suspend_request_data = list(broadcast_message_data.values())
2222#
2223#             dm1_request = CANFrame(pgn=PGN["Stop Start Broadcast"], data=dm1_suspend_request_data)
2224#             bus.send_message(dm1_request.message())
2225#
2226#             if mystery_response := bus.read_input():
2227#                 message = "Broadcast was not suspended after Stop Start Broadcast command"
2228#                 logger.write_warning_to_report(message)
2229#                 logger.write_warning_to_report(f"Broadcast received: {mystery_response}")
2230#             else:
2231#                 message = "Broadcast was suspended after Stop Start Broadcast command"
2232#                 logger.write_info_to_report(message)
2233#
2234#             logger.write_result_to_html_report("Stop Start Broadcast command was successful")
2235#
2236
2237
2238class TestVehicleElectricalPower:
2239    """Test Vehicle Electrical Power command is J1939 Compliant"""
2240
2241    @pytest.mark.profile
2242    def test_profile(self) -> None:
2243        """
2244        | Description          | Test Vehicle Electrical Power #5 (VEP5) command                  |
2245        | :------------------- | :--------------------------------------------------------------- |
2246        | GitHub Issue         | turnaroundfactor/BMS-HW-Test#389                                 |
2247        | Instructions         | 1. Send Vehicle Electrical Power #5 command                 </br>\
2248                                 2. Check values in data                                     </br>\
2249                                 3. Log response                                                  |
2250        | Estimated Duration   | 21 seconds                                                       |
2251        """
2252
2253        with CANBus(BATTERY_CHANNEL) as bus:
2254            vehicle_electrical_power_request = CANFrame(
2255                destination_address=_GENERATED_PROFILE.address,
2256                pgn=PGN["Request"],
2257                data=[PGN["Vehicle Electrical Power #5"].id],
2258            )
2259            if vehicle_electrical_power_response := bus.process_call(vehicle_electrical_power_request):
2260                if vehicle_electrical_power_response.pgn.id != PGN["Vehicle Electrical Power #5", [32]].id:
2261                    if vehicle_electrical_power_response.pgn.id == 59392:
2262                        message = (
2263                            f"Received PGN {vehicle_electrical_power_response.pgn.id} "
2264                            f"({spn_types.acknowledgement(vehicle_electrical_power_response.data[0])}), "
2265                            f'not PGN {PGN["Vehicle Electrical Power #5", [32]].id} '
2266                        )
2267                        _GENERATED_PROFILE.vehicle_electrical_power = BadStates.NACK.name
2268                        _GENERATED_PROFILE.vehicle_electrical_specification = "invalid"
2269                    else:
2270                        message = (
2271                            f"Received PGN {vehicle_electrical_power_response.pgn.id} "
2272                            f"{vehicle_electrical_power_response.pgn.short_name}, "
2273                            f'not PGN {PGN["Vehicle Electrical Power #5", [32]].id} '
2274                        )
2275                        _GENERATED_PROFILE.vehicle_electrical_power = BadStates.WRONG_PGN.name
2276                        _GENERATED_PROFILE.vehicle_electrical_specification = "invalid"
2277                    logger.write_warning_to_html_report(message)
2278                    return
2279            else:
2280                message = (
2281                    f"Battery did not respond to PGN {PGN['Vehicle Electrical Power #5', [32]].id} "
2282                    f"Vehicle Electrical Power #5 request"
2283                )
2284                _GENERATED_PROFILE.vehicle_electrical_power = BadStates.NO_RESPONSE.name
2285                _GENERATED_PROFILE.vehicle_electrical_specification = "invalid"
2286                logger.write_warning_to_html_report(message)
2287                return
2288
2289            vep5_data = vehicle_electrical_power_response.data
2290            vep5_pgn = PGN["Vehicle Electrical Power #5"].data_field
2291
2292            logger.write_result_to_html_report("<span style='font-weight: bold'>Vehicle Electrical Power #5</span>")
2293
2294            for spn, elem in zip(vep5_pgn, vep5_data):
2295                if spn.name in ["Reserved", "SLI Battery Pack State of Charge"]:
2296                    continue
2297
2298                description = f"({desc})" if (desc := spn.data_type(elem)) else ""
2299                logger.write_result_to_html_report(f"{spn.name}: {elem} {description}")
2300                high_value = 1.0
2301
2302                if spn.name == "SLI Battery Pack Capacity":
2303                    high_value = 64255
2304
2305                if spn.name == "SLI Battery Pack Health":
2306                    high_value = 125
2307
2308                if spn.name == "SLI Cranking Predicted Minimum Battery Voltage":
2309                    high_value = 50
2310
2311                if not 0 <= elem <= high_value:
2312                    logger.write_warning_to_html_report(
2313                        f"{spn.name}: {cmp(elem, '>=', 0, description)} and "
2314                        f"{cmp(elem, '<=', high_value, description)}"
2315                    )
2316                    _GENERATED_PROFILE.vehicle_electrical_specification = "invalid"
2317
2318            vehicle_electrical_power = {
2319                "sli_battery_pack_capacity": vep5_data[1],
2320                "sli_battery_pack_health": vep5_data[2],
2321                "sli_cranking_predicted_minimum_battery_voltage": vep5_data[3],
2322            }
2323
2324            if not hasattr(_GENERATED_PROFILE, "vehicle_electrical_specification"):
2325                _GENERATED_PROFILE.vehicle_electrical_specification = "valid"
2326
2327            _GENERATED_PROFILE.vehicle_electrical_power = vehicle_electrical_power
2328            logger.write_result_to_html_report("Test Vehicle Electrical Power #5 command was successful")
2329
2330    def test_comparison(self) -> None:
2331        """
2332        | Description          | Compare if Vehicle Electrical Power #5 values matched            |
2333        | :------------------- | :--------------------------------------------------------------- |
2334        | GitHub Issue         | turnaroundfactor/BMS-HW-Test#393                                 |
2335        | Instructions         | 1. Check if Vehicle Electrical Power #5 matched in profiles      |
2336        | Pass / Fail Criteria | Pass if Vehicle Electrical Power #5 values match                 |
2337        | Estimated Duration   | 1 second                                                         |
2338        """
2339
2340        failed_test = False
2341        if not hasattr(_GENERATED_PROFILE, "vehicle_electrical_power") or not hasattr(
2342            _COMPARISON_PROFILE, "vehicle_electrical_power"
2343        ):
2344            message = "Nothing to compare. The profile test may have failed or been skipped"
2345            logger.write_result_to_html_report(message)
2346            pytest.skip(message)
2347
2348        if isinstance(_GENERATED_PROFILE.vehicle_electrical_power, str) or isinstance(
2349            _COMPARISON_PROFILE.vehicle_electrical_power, str
2350        ):
2351            if not isinstance(
2352                _GENERATED_PROFILE.vehicle_electrical_power, type(_COMPARISON_PROFILE.vehicle_electrical_power)
2353            ):
2354                message = "Unable to compare profiles, Vehicle Electrical Power #5 types did not match"
2355                logger.write_failure_to_html_report(message)
2356                pytest.fail(message)
2357
2358            comparison = cmp(
2359                _GENERATED_PROFILE.vehicle_electrical_power, "==", _COMPARISON_PROFILE.vehicle_electrical_power
2360            )
2361
2362            if _GENERATED_PROFILE.vehicle_electrical_power != _COMPARISON_PROFILE.vehicle_electrical_power:
2363                message = f"Vehicle Electrical Power #5 did not match: {comparison}"
2364                logger.write_failure_to_html_report(message)
2365                pytest.fail(message)
2366
2367            message = f"Vehicle Electrical Power #5 matched: {comparison}"
2368            logger.write_result_to_html_report(message)
2369        else:
2370
2371            failed_categories = []
2372            percent_closeness = 0.05
2373
2374            for key, value in _GENERATED_PROFILE.vehicle_electrical_power.items():
2375                key_text = key.replace("_", " ").title()
2376                key_text = key_text.replace("Sli", "SLI")
2377                diff = abs(value - _COMPARISON_PROFILE.vehicle_electrical_power[key])
2378                average = (value + _COMPARISON_PROFILE.vehicle_electrical_power[key]) / 2
2379                percentage = round(diff / average, 4)
2380
2381                comparison = cmp(percentage * 100, "<=", percent_closeness * 100, "%")
2382                message = f"{key_text}: {comparison}"
2383
2384                if not math.isclose(
2385                    value,
2386                    _COMPARISON_PROFILE.vehicle_electrical_power[key],
2387                    rel_tol=percent_closeness,
2388                ):
2389                    logger.write_warning_to_html_report(message)
2390                    failed_categories.append(key_text)
2391                else:
2392                    logger.write_result_to_html_report(message)
2393
2394            fail_length = len(failed_categories)
2395
2396            if fail_length > 0:
2397                categories = "data values" if fail_length > 1 else "data value"
2398                message = f"{fail_length} {categories} failed profile comparisons: {', '.join(failed_categories)}"
2399                logger.write_failure_to_html_report(message)
2400                failed_test = True
2401            else:
2402                logger.write_result_to_html_report(
2403                    f"Vehicle Electrical Power #5 values between profiles were within {percent_closeness * 100}%"
2404                )
2405
2406        if not hasattr(_GENERATED_PROFILE, "vehicle_electrical_specification") or not hasattr(
2407            _COMPARISON_PROFILE, "vehicle_electrical_specification"
2408        ):
2409            logger.write_warning_to_html_report("Missing specification information")
2410        else:
2411            comparison = cmp(
2412                _GENERATED_PROFILE.vehicle_electrical_specification,
2413                "==",
2414                _COMPARISON_PROFILE.vehicle_electrical_specification,
2415            )
2416            if (
2417                _GENERATED_PROFILE.vehicle_electrical_specification
2418                != _COMPARISON_PROFILE.vehicle_electrical_specification
2419            ):
2420                logger.write_failure_to_html_report(f"Specification comparison failed: {comparison}")
2421                failed_test = True
2422            else:
2423                logger.write_result_to_html_report(f"Specification comparison passed: {comparison}")
2424
2425        if failed_test:
2426            message = "Comparisons did not match in profiles"
2427            logger.write_failure_to_html_report(message)
2428            pytest.fail(message)
2429
2430
2431class TestManufacturerCommands:
2432    """Test Manufacturer Commands are compliant with specs"""
2433
2434    def saft_commands_test(self):
2435        """Tests SAFT Manufacturer Commands"""
2436        with CANBus(BATTERY_CHANNEL) as bus:
2437            manufactured_command_request = CANFrame(
2438                destination_address=_GENERATED_PROFILE.address, pgn=PGN["RQST"], data=[0]
2439            )
2440            invalid_response = []
2441            for address in itertools.chain(
2442                [0xFFD2], range(0xFFD4, 0xFFD9), range(0xFFDC, 0xFFDF), range(0xFFE0, 0xFFE2), [0xFFE4]
2443            ):
2444                manufactured_command_request.data = [address]
2445                default_pgn = PGN[address]
2446                pgn_name = default_pgn.name
2447                logger.write_result_to_html_report(
2448                    f"<span style='font-weight: bold'>PGN {address} ({pgn_name})---</span>"
2449                )
2450
2451                if response_frame := bus.process_call(manufactured_command_request):
2452                    if not response_frame.pgn.id == default_pgn.id:
2453
2454                        if response_frame.pgn.id == PGN["ACKM", [32]].id:
2455                            message = (
2456                                f"Expected {address} ({pgn_name}): Received PGN {response_frame.pgn.id} "
2457                                f"({spn_types.acknowledgement(response_frame.data[0])}) "
2458                            )
2459                            logger.write_warning_to_html_report(message)
2460                        else:
2461                            logger.write_warning_to_html_report(
2462                                f"Expected PGN {address} ({pgn_name}), but received "
2463                                f"{response_frame.pgn.id} ({response_frame.pgn.name}). "
2464                                f"Unable to complete check for command"
2465                            )
2466                        invalid_response.append(f"PGN {address} ({pgn_name})")
2467                        continue
2468                else:
2469                    message = f"Did not receive response from PGN {address} {pgn_name}"
2470                    logger.write_warning_to_html_report(message)
2471                    invalid_response.append(f"PGN {address} ({pgn_name})")
2472
2473                    continue
2474
2475                if response_frame.priority != default_pgn.default_priority:
2476                    message = (
2477                        f"Expected priority level of {default_pgn.default_priority}"
2478                        f" but got priority level {response_frame.priority} for PGN {address}, {pgn_name}"
2479                    )
2480                    invalid_response.append(f"PGN {address} {pgn_name}")
2481                    logger.write_warning_to_html_report(message)
2482
2483                if len(response_frame.packed_data) != 8:
2484                    message = (
2485                        f"Unexpected data length for PGN {address}, {pgn_name}. Expected length of 8, "
2486                        f"received {len(response_frame.packed_data)}"
2487                    )
2488                    logger.write_warning_to_html_report(message)
2489
2490                not_passed_elem = []
2491                for spn, elem in zip(default_pgn.data_field, response_frame.data):
2492                    low_range = 0
2493                    high_range = 3
2494                    spn_name = spn.name
2495                    if spn.name == "Reserved":
2496                        continue
2497
2498                    description = f"({desc})" if (desc := spn.data_type(elem)) else ""
2499
2500                    logger.write_result_to_html_report(f"{spn_name}: {elem}{description}")
2501
2502                    if pgn_name == "Battery ECU Status":
2503                        if spn_name in ("Battery Mode", "FET Array State", "SOC Mode", "Heat Reason"):
2504                            high_range = 7
2505                        elif spn_name == "Long-Term Fault Log Status":
2506                            high_range = 15
2507                        elif spn_name == "Software Part Number":
2508                            high_range = 64255
2509
2510                    if pgn_name in ("Battery Cell Status 1", "Battery Cell Status 2"):
2511                        high_range = 6.4255
2512
2513                    if pgn_name == "Battery Performance":
2514                        if spn_name == "Battery Current":
2515                            low_range = -82000.00
2516                            high_range = 82495.35
2517                        if spn_name == "Internal State of Health":
2518                            low_range = -204.800
2519                            high_range = 204.775
2520
2521                    if pgn_name == "Battery Temperatures":
2522                        if spn_name == "MCU Temperature":
2523                            low_range = -40
2524                            high_range = 210
2525                        else:
2526                            low_range = -50
2527                            high_range = 200
2528
2529                    if pgn_name == "Battery Balancing Circuit Info":
2530                        if spn.name == "Cell Voltage Difference":
2531                            high_range = 6.4255
2532                        if spn_name == "Cell Voltage Sum":
2533                            high_range = 104.8576
2534
2535                    if pgn_name in ("Battery Cell Upper SOC", "Battery Cell Lower SOC"):
2536                        low_range = -10
2537                        high_range = 115
2538
2539                    if pgn_name == "Battery Function Status":
2540                        if spn_name == "Heater Set Point":
2541                            low_range = -50
2542                            high_range = 25
2543                        if spn_name == "Storage Delay Time Limit":
2544                            high_range = 65535
2545                        if spn_name == "Last Storage Duration (Minutes)":
2546                            high_range = 59
2547                        if spn_name == "Last Storage Duration (Hours)":
2548                            high_range = 23
2549                        if spn_name == "Last Storage Duration (Days)":
2550                            high_range = 31
2551                        if spn_name == "Last Storage Duration (Months)":
2552                            high_range = 255
2553                        if spn_name == "Effective Reset Time":
2554                            high_range = 60
2555
2556                    if not low_range <= elem <= high_range:
2557                        logger.write_warning_to_html_report(
2558                            f"{spn.name}: {cmp(elem, '>=', 0, description)} and "
2559                            f"{cmp(elem, '<=', high_range, description)}"
2560                        )
2561                        not_passed_elem.append(spn_name)
2562
2563                if len(not_passed_elem) == 0:
2564                    message = f"✅ All data fields in PGN {default_pgn.id} ({pgn_name}) met requirements"
2565                    logger.write_result_to_html_report(message)
2566
2567            if len(invalid_response) > 0:
2568                message = (
2569                    f"{len(invalid_response)} SAFT Manufacturer Command{'s' if len(invalid_response) > 1 else ''} "
2570                    f"failed: {', '.join(invalid_response)}"
2571                )
2572                logger.write_warning_to_html_report(message)
2573            else:
2574                logger.write_result_to_html_report("All SAFT Manufacturer Commands passed")
2575
2576    @pytest.mark.profile
2577    def test_profile(self) -> None:
2578        """
2579        | Description          | Fingerprint Manufacturer Commands                                |
2580        | :------------------- | :--------------------------------------------------------------- |
2581        | GitHub Issue         | turnaroundfactor/BMS-HW-Test#388                                 |
2582        | Instructions         | 1. Send request for manufacturer command                    </br>\
2583                                 2. Check values in data                                     </br>\
2584                                 3. Log response                                                  |
2585        | Estimated Duration   | 22 seconds                                                       |
2586        """
2587
2588        if _GENERATED_PROFILE.manufacturer_code == ManufacturerID.SAFT:
2589            self.saft_commands_test()
2590        else:
2591            message = "No known manufacturer commands to test"
2592            logger.write_result_to_html_report(message)
2593            pytest.skip(message)
2594
2595
2596# class TestUDSSession:
2597#     """Tests if batteries respond to UDS Session"""
2598#
2599#     @pytest.mark.profile
2600#     def test_profile(self) -> None:
2601#         """
2602#         | Description          | Test if batteries respond to UDS Session                         |
2603#         | :------------------- | :--------------------------------------------------------------- |
2604#         | GitHub Issue         | turnaroundfactor/BMS-HW-Test#294                                 |
2605#         | Instructions         | 1. Send request for UDS Session                             </br>\
2606#                                  2. Check if battery responds                                </br>\
2607#                                  3. Log response                                                  |
2608#         | Estimated Duration   | 10 minutes                                                       |
2609#         """
2610#
2611#         request_data_params = ["0000", "F18C"]
2612#
2613#         can_256_ids = []
2614#         for number in range(256):
2615#             can_id = 0x18DA00F1 | number << 8
2616#             can_256_ids.append(can_id)
2617#
2618#         read_data_commands = []
2619#         # Read Data by Identifier
2620#         with CANBus(BATTERY_CHANNEL) as bus:
2621#             for address in itertools.chain(
2622#                 [0x7DF], range(0x7E0, 0x7E8), range(0x7E8, 0x7F0), [0x18DB33F1], can_256_ids
2623#             ):
2624#                 respond_command = []
2625#                 for param in request_data_params:
2626#                     data = f"0322{param}AAAAAAAA"
2627#                     bytes_data = bytes.fromhex(data)
2628#                     bus.send_message(can.Message(arbitration_id=address, data=bytes_data))
2629#                     if response := bus.read_input(timeout=0.5):
2630#                         data_frame = CANFrame.decode(response.arbitration_id, response.data)
2631#                         if data_frame.pgn.id != PGN["ACKM"].id:
2632#                             logger.write_info_to_report(
2633#                                 f"UDS Read Memory by Address response received for address {address}: {response}"
2634#                             )
2635#                             respond_command.append(data)
2636#
2637#                 if respond_command:
2638#                     read_data_commands.append(address)
2639#
2640#             if read_data_commands:
2641#                 logger.write_info_to_report(
2642#                     f"The following CAN addresses responded to UDS request: {read_data_commands}"
2643#                 )
2644#             else:
2645#                 logger.write_info_to_report("No UDS responses were received for Read Data By Identifier")
2646#
2647#             # Read Memory Address
2648#             read_memory_commands = []
2649#             for address in itertools.chain(
2650#                 [0x7DF], range(0x7E0, 0x7E8), range(0x7E8, 0x7F0), [0x18DB33F1], can_256_ids
2651#             ):
2652#                 respond_command = []
2653#                 data = "03230000AAAAAAAA"
2654#                 bytes_data = bytes.fromhex(data)
2655#                 bus.send_message(can.Message(arbitration_id=address, data=bytes_data))
2656#                 if response := bus.read_input(timeout=0.5):
2657#                     data_frame = CANFrame.decode(response.arbitration_id, response.data)
2658#                     if data_frame.pgn.id != PGN["ACKM"].id:
2659#                         logger.write_info_to_report(
2660#                             f"UDS Read Memory by Address response received for address {address}: {response}"
2661#                         )
2662#                         respond_command.append(data)
2663#
2664#                 if respond_command:
2665#                     read_memory_commands.append(address)
2666#
2667#             if read_memory_commands:
2668#                 logger.write_info_to_report(
2669#                     f"The following CAN addresses responded to UDS request: {read_memory_commands}"
2670#                 )
2671#             else:
2672#                 logger.write_info_to_report("No UDS responses were received for Read Memory By Address")
2673#
2674#             _GENERATED_PROFILE.uds_read_data_commands = read_data_commands
2675#             _GENERATED_PROFILE.uds_read_memory_commands = read_memory_commands
2676#
2677#     def test_comparison(self) -> None:
2678#         """
2679#         | Description          | Compare UDS responses                                            |
2680#         | :------------------- | :--------------------------------------------------------------- |
2681#         | GitHub Issue         | turnaroundfactor/BMS-HW-Test#294                                 |
2682#         | Instructions         | 1. Check if profiles have similar UDS responses                  |
2683#         | Pass / Fail Criteria | Pass if profiles are closely matched                             |
2684#         | Estimated Duration   | 1 second                                                         |
2685#         """
2686#
2687#         if len(_GENERATED_PROFILE.uds_read_data_commands) == 0 and
2688#         len(_COMPARISON_PROFILE.uds_read_data_commands) == 0:
2689#             logger.write_result_to_report("Both profiles did not receive UDS responses")
2690#
2691#         if (
2692#             len(_GENERATED_PROFILE.uds_read_memory_commands) == 0
2693#             and len(_COMPARISON_PROFILE.uds_read_memory_commands) == 0
2694#         ):
2695#             logger.write_result_to_report("Both profiles did not receive UDS responses")
2696#
2697#         unique_read_data_commands = set(_GENERATED_PROFILE.uds_read_data_commands) - set(
2698#             _COMPARISON_PROFILE.uds_read_data_commands
2699#         )
2700#         unique_read_memory_commands = set(_GENERATED_PROFILE.uds_read_memory_commands) - set(
2701#             _COMPARISON_PROFILE.uds_read_memory_commands
2702#         )
2703#
2704#         if unique_read_data_commands:
2705#             logger.write_warning_to_report(
2706#                 f"Profiles did not match read data CAN addresses: {unique_read_data_commands}"
2707#             )
2708#
2709#         if unique_read_memory_commands:
2710#             logger.write_warning_to_report(
2711#                 f"Profiles did not match read memory CAN addresses: {unique_read_memory_commands}"
2712#             )
2713#
2714#         if not unique_read_data_commands and not unique_read_memory_commands:
2715#             logger.write_result_to_html_report("Profiles shared similar results for UDS requests")
2716#
2717
2718
2719class TestECUInformation:
2720    """Gets information about the physical ECU and its hardware"""
2721
2722    @staticmethod
2723    def bytes_to_ascii(bs: list[float]) -> list[str]:
2724        """Converts bytes to ASCII string"""
2725        s: str = ""
2726        for b in bs:
2727            h = re.sub(r"^[^0-9a-fA-F]+$", "", f"{b:x}")
2728            try:
2729                ba = bytearray.fromhex(h)[::-1]
2730                s += ba.decode("utf-8", "ignore")
2731                s = re.sub(r"[^\x20-\x7E]", "", s)
2732            except ValueError:
2733                # NOTE: This will ignore any invalid packets (from BrenTronics)
2734                logger.write_warning_to_report(f"Skipping invalid hex: {b:x}")
2735        return list(filter(None, s.split("*")))
2736
2737    @pytest.mark.profile
2738    def test_ecu_information(self) -> None:
2739        """
2740        | Description          | Get information from ECUID response                              |
2741        | :------------------- | :--------------------------------------------------------------- |
2742        | GitHub Issue         | turnaroundfactor/BMS-HW-Test#395                                 |
2743        | Instructions         | 1. Request ECUID data                                       </br>\
2744                                 2. Log returned values                                           |
2745        | Estimated Duration   | 30 seconds                                                       |
2746        """
2747
2748        info = []
2749        with CANBus(BATTERY_CHANNEL) as bus:
2750            ecu_request = CANFrame(pgn=PGN["Request"], data=[PGN["ECUID"].id])
2751            if tp_cm_frame := bus.process_call(ecu_request):
2752                if tp_cm_frame is not None:
2753                    if tp_cm_frame.pgn.id == PGN["TP.CM", [32]].id:
2754                        data = []
2755                        cts_request = CANFrame(pgn=PGN["TP.CM"], data=[17, 0, 1, 0xFF, 0])
2756                        if data_frame := bus.process_call(cts_request):
2757                            if data_frame is not None:
2758                                if data_frame.pgn.id == PGN["TP.DT"].id:
2759                                    packet_count = int(tp_cm_frame.data[2])
2760                                    # NOTE: This will ignore the invalid header packet from BrenTronics
2761                                    if _GENERATED_PROFILE.manufacturer_code != ManufacturerID.BRENTRONICS:
2762                                        data.append(data_frame.data[1])
2763                                        packet_count -= 1
2764                                    for _ in range(packet_count):
2765                                        data_message = bus.read_input()
2766                                        if data_message is not None:
2767                                            frame = CANFrame.decode(data_message.arbitration_id, data_message.data)
2768                                            if frame.pgn.id == PGN["TP.DT"].id:
2769                                                data.append(frame.data[1])
2770                                            else:
2771                                                Errors.unexpected_packet("TP.DT", frame)
2772                                                break
2773                                        else:
2774                                            Errors.no_packet("TP.DT")
2775                                            break
2776                                    info = self.bytes_to_ascii(data)
2777                                    eom_request = CANFrame(
2778                                        pgn=PGN["TP.CM"],
2779                                        data=[17, tp_cm_frame.data[1], packet_count, 0xFF, tp_cm_frame.data[-1]],
2780                                    )
2781                                    if eom_frame := bus.process_call(eom_request):
2782                                        if eom_frame is not None:
2783                                            if eom_frame.pgn.id == PGN["DM15"].id:
2784                                                if eom_frame.data[2] == 4:  # Operation Completed
2785                                                    logger.write_info_to_report("ECUID data transfer successful")
2786                                                else:
2787                                                    logger.write_warning_to_html_report("Unsuccessful EOM response")
2788                                            else:
2789                                                Errors.unexpected_packet("DM15", eom_frame)
2790                                        else:
2791                                            Errors.no_packet("DM15")
2792                                    else:
2793                                        # timeout
2794                                        logger.write_warning_to_report("No response after sending EOM (DM15)")
2795                                else:
2796                                    Errors.unexpected_packet("TP.DT", data_frame)
2797                            else:
2798                                Errors.no_packet("TP.DT")
2799                        else:
2800                            message = f"Did not receive response from PGN {PGN['TP.CM', [32]].id}"
2801                            logger.write_warning_to_html_report(message)
2802                            _GENERATED_PROFILE.ecu = BadStates.NO_RESPONSE.name
2803                            _GENERATED_PROFILE.ecu_specification = "invalid"
2804                    else:
2805                        Errors.unexpected_packet("TP.CM", tp_cm_frame)
2806                else:
2807                    Errors.no_packet("TP.CM")
2808            else:
2809                message = f"Did not receive response from PGN {PGN['ECUID', [32]].id}"
2810                logger.write_warning_to_html_report(message)
2811                _GENERATED_PROFILE.ecu = BadStates.NO_RESPONSE.name
2812                _GENERATED_PROFILE.ecu_specification = "invalid"
2813
2814            if len(info) > 0:
2815                _GENERATED_PROFILE.ecu = {
2816                    "part_number": info[0],
2817                    "serial_number": info[1],
2818                    "location_name": info[2],
2819                    "manufacturer": info[3],
2820                    "classification": info[4],
2821                }
2822                logger.write_result_to_html_report("<span style='font-weight: bold'>ECUID Information </span>")
2823                for key, value in _GENERATED_PROFILE.ecu.items():
2824                    logger.write_result_to_html_report(f"{key.strip().replace('_', ' ').title()}: {value}")
2825                _GENERATED_PROFILE.ecu_specification = "valid"
2826
2827            else:
2828                _GENERATED_PROFILE.ecu = BadStates.INVALID_RESPONSE.name
2829                logger.write_warning_to_html_report("Could not get ECU information")
2830                _GENERATED_PROFILE.ecu_specification = "invalid"
2831
2832    def test_comparison(self) -> None:
2833        """
2834        | Description          | Compare ECU Information                                          |
2835        | :------------------- | :--------------------------------------------------------------- |
2836        | GitHub Issue         | turnaroundfactor/BMS-HW-Test#395                                 |
2837        | Instructions         | 1. Confirm identity numbers are unique for each battery          |
2838        | Pass / Fail Criteria | Pass if ECU values match / serial number is unique               |
2839        | Estimated Duration   | 1 second                                                         |
2840        """
2841
2842        failed_test = False
2843        if not hasattr(_GENERATED_PROFILE, "ecu") or not hasattr(_COMPARISON_PROFILE, "ecu"):
2844            logger.write_result_to_html_report("Nothing to compare. The profile test may have failed or been skipped")
2845            pytest.skip("Nothing to compare. The profile test may have failed or been skipped")
2846
2847        if isinstance(_GENERATED_PROFILE.ecu, str) or isinstance(_COMPARISON_PROFILE.ecu, str):
2848            if not isinstance(_GENERATED_PROFILE.ecu, type(_COMPARISON_PROFILE.ecu)):
2849                message = "Unable to compare profiles, ECU types did not match"
2850                logger.write_failure_to_html_report(message)
2851                pytest.fail(message)
2852
2853            comparison = cmp(_GENERATED_PROFILE.ecu, "==", _COMPARISON_PROFILE.ecu)
2854
2855            if _GENERATED_PROFILE.ecu != _COMPARISON_PROFILE.ecu:
2856                message = f"ECU did not match: {comparison}"
2857                logger.write_failure_to_html_report(message)
2858                pytest.fail(message)
2859
2860            message = f"ECU matched: {comparison}"
2861            logger.write_result_to_html_report(message)
2862        else:
2863
2864            failed_categories = []
2865
2866            for key, value in _GENERATED_PROFILE.ecu.items():
2867                key_text = key.replace("_", " ").title()
2868
2869                comparison = cmp(value, "==", _COMPARISON_PROFILE.ecu[key])
2870
2871                if key == "serial_number":
2872                    if value == _COMPARISON_PROFILE.ecu[key]:
2873                        message = f"{key_text} matched: {comparison}"
2874                        logger.write_warning_to_html_report(message)
2875                    else:
2876                        message = f"{key_text}: {comparison}"
2877                        logger.write_result_to_html_report(message)
2878
2879                elif value != _COMPARISON_PROFILE.ecu[key]:
2880                    message = f"{key_text} did not match: {comparison}"
2881                    logger.write_warning_to_html_report(message)
2882                    failed_categories.append(key_text)
2883                else:
2884                    message = f"{key_text}: {comparison}"
2885                    logger.write_result_to_html_report(message)
2886
2887            fail_length = len(failed_categories)
2888
2889            if fail_length > 0:
2890                categories = "data values" if fail_length > 1 else "data value"
2891                message = f"{fail_length} {categories} failed profile comparisons: {', '.join(failed_categories)}"
2892                logger.write_failure_to_html_report(message)
2893                failed_test = True
2894
2895        if not hasattr(_GENERATED_PROFILE, "ecu_specification") or not hasattr(
2896            _COMPARISON_PROFILE, "ecu_specification"
2897        ):
2898            logger.write_warning_to_html_report("Missing specification information")
2899        else:
2900            comparison = cmp(
2901                _GENERATED_PROFILE.ecu_specification,
2902                "==",
2903                _COMPARISON_PROFILE.ecu_specification,
2904            )
2905            if _GENERATED_PROFILE.ecu_specification != _COMPARISON_PROFILE.ecu_specification:
2906                logger.write_failure_to_html_report(f"Specification comparison failed: {comparison}")
2907                failed_test = True
2908            else:
2909                logger.write_result_to_html_report(f"Specification comparison passed: {comparison}")
2910
2911        if failed_test:
2912            message = "Comparisons did not meet expectations"
2913            logger.write_failure_to_html_report(message)
2914            pytest.fail(message)
2915        else:
2916            logger.write_result_to_html_report("ECUID profile comparisons met requirements")
2917
2918
2919class TestAnalyzeTimingCommands:
2920    """Test Supported Commands & Conduct Timing Analysis"""
2921
2922    @pytest.mark.profile
2923    def test_analyze_timing_commands(self) -> None:
2924        """
2925        | Description          | Analyze timing of commands                                       |
2926        | :------------------- | :--------------------------------------------------------------- |
2927        | GitHub Issue         | turnaroundfactor/BMS-HW-Test#383                                 |
2928        | Instructions         | 1. Make requests for supported commands                     </br>\
2929                                 2. Conduct timing analysis                                  </br>\
2930                                 3. Log average times                                             |
2931        | Estimated Duration   | 35 seconds                                                       |
2932        """
2933
2934        with CANBus(BATTERY_CHANNEL) as bus:
2935            j1939_transmit_commands = [0xEE00, 0xFEE6, 0xFCB6, 0xFECA, 0xFECB, 0xFE50, 0xFDC5, 0xFEDA, 0xD800, 0xE800]
2936            mil_prf_commands = [0xFF00, 0xFF01, 0xFF02, 0xFF03, 0xFF04, 0xFF05, 0xFF06, 0xFF07, 0xFF08]
2937            if hasattr(_GENERATED_PROFILE, "proprietary_commands"):
2938                proprietary_commands = list(_GENERATED_PROFILE.proprietary_commands.keys())
2939            else:
2940                proprietary_commands = []
2941
2942            # Remove potential duplicate commands
2943            commands_list = list(dict.fromkeys(j1939_transmit_commands + mil_prf_commands + proprietary_commands))
2944
2945            times_list: dict[int | Any, list[Any]] = {}
2946            average_times = {}
2947            no_valid_response: dict[int, bool] = {}
2948            for _ in range(0, 3):
2949                for command in commands_list:
2950                    command_name = PGN[command].name
2951                    if no_valid_response.get(command):
2952                        continue
2953
2954                    request_frame = CANFrame(
2955                        destination_address=_GENERATED_PROFILE.address, pgn=PGN["RQST"], data=[command]
2956                    )
2957                    start_time = time.perf_counter()
2958                    if response := bus.process_call(request_frame):
2959                        if response.pgn.id == PGN["TP.CM", [32]].id:
2960                            expected_bytes = int(response.data[1])
2961                            expected_packets = 0
2962                            if _GENERATED_PROFILE.manufacturer_code == ManufacturerID.BRENTRONICS:
2963                                expected_packets = int(response.data[2] + 1)
2964                            else:
2965                                expected_packets = int(response.data[2])
2966
2967                            rts_pgn_id = int(response.data[-1])
2968                            if command in (PGN["ECUID", [32]].id, PGN["SOFT", [32]].id):
2969                                cts_request = CANFrame(pgn=PGN["TP.CM"], data=[17, 0, 1, 0xFF, 0])
2970                            else:
2971                                cts_request = CANFrame(pgn=PGN["TP.CM"], data=[19, 0, 0, 0xFF, 0])
2972                            cts_request.data[1] = expected_packets
2973                            cts_request.data[-1] = rts_pgn_id
2974
2975                            bus.send_message(cts_request.message())
2976                            for i in range(expected_packets):
2977                                data_frame = bus.read_frame()
2978                                if data_frame is None:
2979                                    message = (
2980                                        f"Received no data packet when in TP.CM protocol for command"
2981                                        f" {command}, {command_name}"
2982                                    )
2983                                    logger.write_warning_to_html_report(message)
2984                                if data_frame.pgn.id != PGN["TP.DT", [32]].id:
2985                                    message = (
2986                                        f"Expected data frame {i + 1}, got PGN {data_frame.pgn.id} "
2987                                        f"for PGN request {command} ({command_name}) instead"
2988                                    )
2989                                    logger.write_warning_to_html_report(message)
2990
2991                            # Send acknowledgement frame
2992                            end_time = time.perf_counter()
2993                            end_acknowledge_frame = CANFrame(pgn=PGN["TP.CM"], data=[19, 0, 0, 0xFF, 0])
2994                            end_acknowledge_frame.data[1] = expected_bytes
2995                            end_acknowledge_frame.data[2] = expected_packets
2996                            end_acknowledge_frame.data[-1] = rts_pgn_id
2997                            bus.send_message(end_acknowledge_frame.message())
2998                        else:
2999                            end_time = time.perf_counter()
3000
3001                        if response.pgn.id in (command, PGN["TP.CM", [32]].id):
3002                            if not times_list.get(command):
3003                                times_list[command] = [end_time - start_time]
3004                            else:
3005                                times_list[command].append(end_time - start_time)
3006                        else:
3007                            if response.pgn.id == PGN["ACKM", [32]].id:
3008                                message = (
3009                                    f"PGN {command} ({command_name}): Received PGN {response.pgn.id} "
3010                                    f"({spn_types.acknowledgement(response.data[0])}) "
3011                                )
3012                                average_times[command] = (
3013                                    f"PGN {response.pgn.id} ({spn_types.acknowledgement(response.data[0])})"
3014                                )
3015                            else:
3016                                message = (
3017                                    f"PGN {command} ({command_name}): Received PGN {response.pgn.id} "
3018                                    f"({response.pgn.name})."
3019                                )
3020                                average_times[command] = f"PGN {response.pgn.id} ({response.pgn.name})"
3021                            logger.write_warning_to_html_report(message)
3022                            no_valid_response[command] = True
3023                            continue
3024                    else:
3025                        logger.write_warning_to_html_report(
3026                            f"Did not receive any response for PGN {command} ({command_name})"
3027                        )
3028                        no_valid_response[command] = True
3029                        average_times[command] = "No response"
3030                        continue
3031
3032            for pgn, item in times_list.items():
3033                times = item
3034                average = sum(times) / len(times)
3035                average_times[pgn] = average
3036                message = f"PGN {pgn} ({PGN[pgn].name}) average time: {average:.6f} seconds"
3037                logger.write_result_to_html_report(message)
3038
3039            _GENERATED_PROFILE.average_times = average_times
3040
3041    def test_comparison(self) -> None:
3042        """
3043        | Description          | Compare average time values                                      |
3044        | :------------------- | :--------------------------------------------------------------- |
3045        | GitHub Issue         | turnaroundfactor/BMS-HW-Test#393                                 |
3046        | Instructions         | 1. Check if profiles complete commands in approx. same time      |
3047        | Pass / Fail Criteria | Pass if profiles time completion are closely matched             |
3048        | Estimated Duration   | 1 second                                                         |
3049        """
3050
3051        if not hasattr(_GENERATED_PROFILE, "average_times") or not hasattr(_COMPARISON_PROFILE, "average_times"):
3052            message = "Nothing to compare. The profile test may have failed or been skipped"
3053            logger.write_failure_to_html_report(message)
3054            pytest.fail(message)
3055
3056        generated_averages = _GENERATED_PROFILE.average_times
3057        comparison_averages = _COMPARISON_PROFILE.average_times
3058
3059        averages_not_close = []
3060        missing_commands = []
3061        percent_closeness = 0.50
3062        for pgn in generated_averages:
3063            if not comparison_averages.get(str(pgn)):
3064                logger.write_warning_to_html_report(f"Missing PGN {pgn} ({PGN[pgn].name}) from comparison profile")
3065                missing_commands.append(pgn)
3066                continue
3067
3068            if isinstance(generated_averages[pgn], str) or isinstance(comparison_averages[str(pgn)], str):
3069                if generated_averages[pgn] != comparison_averages[str(pgn)]:
3070                    logger.write_warning_to_html_report(
3071                        f"PGN {pgn} ({PGN[pgn].name}): {generated_averages[pgn]}{comparison_averages[str(pgn)]}"
3072                    )
3073                else:
3074                    logger.write_result_to_html_report(
3075                        f"PGN {pgn} ({PGN[pgn].name}): {generated_averages[pgn]} = {comparison_averages[str(pgn)]}"
3076                    )
3077                continue
3078
3079            diff = abs(comparison_averages[str(pgn)] - generated_averages[pgn])
3080            average = (generated_averages[pgn] + comparison_averages[str(pgn)]) / 2
3081            percentage = round((diff / average), 4)
3082            comparison = cmp(
3083                percentage * 100,
3084                "<=",
3085                percent_closeness * 100,
3086                "%",
3087            )
3088
3089            if not math.isclose(
3090                generated_averages[pgn],
3091                comparison_averages[str(pgn)],
3092                rel_tol=percent_closeness,
3093            ):
3094                logger.write_warning_to_html_report(f"PGN {pgn} ({PGN[pgn].name}) average percentage: {comparison}")
3095                averages_not_close.append(pgn)
3096            else:
3097                logger.write_result_to_html_report(f"PGN {pgn} ({PGN[pgn].name}) average percentage: {comparison}")
3098
3099        length_not_close = len(averages_not_close)
3100        if length_not_close > 0:
3101            logger.write_failure_to_html_report(
3102                f"{length_not_close} comparison{'s' if length_not_close > 1 else ''} "
3103                f"did not meet expectations. See warnings above for more details."
3104            )
3105            pytest.fail("Some comparison times were not within range")
3106
3107        if len(missing_commands) > 0:
3108            logger.write_failure_to_html_report(f"{len(missing_commands)} PGNs were missing from comparison profile")
3109
3110        logger.write_result_to_html_report(
3111            f"All PGN Average times for profiles were within expected range of {percent_closeness * 100}%"
3112        )
3113
3114
3115def memory_test(mode: Modes) -> list[int]:
3116    """Memory tester helper"""
3117    found_addresses = []
3118    with CANBus(BATTERY_CHANNEL) as bus:
3119        read_request_frame = CANFrame(pgn=PGN["DM14"], data=[1, 1, 1, 0, 0, 0, 0, 0])
3120        read_request_frame.data[2] = mode
3121        addresses = [0, 1107296256, 2147483648, 4294966271]  # 0x0, 0x42000000, 0x80000000, 0xfffffbff
3122        for low_address in addresses:
3123            found = 0
3124            high_address = low_address + (1024 if FULL_MEMORY_TESTS else 16)
3125            for i in range(low_address, high_address):
3126                read_request_frame.data[5] = low_address
3127                if response_frame := bus.process_call(read_request_frame, timeout=1):  # NOTE: SAFT times out
3128                    if response_frame is not None:
3129                        logger.write_info_to_report(
3130                            f"Address {i} responded with PGN {response_frame.pgn.id} "
3131                            f"({response_frame.pgn.short_name}) - status is: "
3132                            f"{spn_types.dm15_status(response_frame.data[3])}"
3133                        )
3134                        if response_frame.pgn.id == PGN["DM15"].id:
3135                            if response_frame.data[5] != 258:  # Invalid Length
3136                                if response_frame.data[3] == 0:
3137                                    found_addresses.append(i)
3138                                    found += 1
3139                        else:
3140                            Errors.unexpected_packet("DM15", response_frame)
3141                            break
3142                    else:
3143                        Errors.no_packet("DM15")
3144                        break
3145                else:
3146                    # timeout
3147                    pass
3148            verb = ""
3149            match mode:
3150                case Modes.READ:
3151                    verb = "readable"
3152                case Modes.WRITE:
3153                    verb = "writable"
3154                case Modes.ERASE:
3155                    verb = "erasable"
3156                case Modes.BOOT:
3157                    verb = "boot load"
3158            message = (
3159                f"Found {found} {verb} successful address(es) in memory ranges"
3160                f" {hex(low_address)}-{hex(high_address)}"
3161            )
3162            logger.write_result_to_html_report(message)
3163
3164    if len(found_addresses) > 0:
3165        logger.write_result_to_html_report(
3166            f"Found {len(found_addresses)} {verb} memory address(es) out of "
3167            f"{4096 if FULL_MEMORY_TESTS else 64} possible addresses"
3168        )
3169    else:
3170        message = f"Found 0 {verb} memory addresses out of {4096 if FULL_MEMORY_TESTS else 64} possible addresses"
3171        logger.write_warning_to_html_report(message)
3172    return found_addresses
3173
3174
3175class TestMemoryRead:
3176    """This will test the read capability of the memory"""
3177
3178    @pytest.mark.profile
3179    def test_read(self) -> None:
3180        """
3181        | Description          | Try to read from different memory locations                      |
3182        | :------------------- | :--------------------------------------------------------------- |
3183        | GitHub Issue         | turnaroundfactor/BMS-HW-Test#379                            </br>\
3184                                 turnaroundfactor/BMS-HW-Test#380                                 |
3185        | Instructions         | 1. Request memory read                                      </br>\
3186                                 2. Log any successful addresses                                  |
3187        | Estimated Duration   | 85 seconds                                                       |
3188        """
3189
3190        _GENERATED_PROFILE.read_addresses = memory_test(Modes.READ)
3191
3192    def test_comparison(self) -> None:
3193        """
3194        | Description          | Compare read results                                             |
3195        | :------------------- | :--------------------------------------------------------------- |
3196        | GitHub Issue         | turnaroundfactor/BMS-HW-Test#379                            </br>\
3197                                 turnaroundfactor/BMS-HW-Test#380                                 |
3198        | Instructions         | Confirm no new memory addresses are readable                     |
3199        | Pass / Fail Criteria | Pass if no new readable memory addresses                         |
3200        | Estimated Duration   | 1 second                                                         |
3201        """
3202
3203        if hasattr(_GENERATED_PROFILE, "read_addresses") and hasattr(_COMPARISON_PROFILE, "read_addresses"):
3204            new_addresses = set(_GENERATED_PROFILE.read_addresses) - set(_COMPARISON_PROFILE.read_addresses)
3205            if new_addresses:
3206                message = (
3207                    f"{len(new_addresses)} Different readable memory address(es) found: "
3208                    f"{', '.join(map(str, new_addresses))}"
3209                )
3210                logger.write_failure_to_html_report(message)
3211                pytest.fail(message)
3212
3213            logger.write_result_to_html_report("Profiles had matching readable memory address responses")
3214
3215        else:
3216            message = "Nothing to compare. The profile test may have failed or been skipped"
3217            logger.write_result_to_html_report(message)
3218            pytest.skip(message)
3219
3220
3221class TestMemoryWrite:
3222    """This will test the write capability of the memory"""
3223
3224    @pytest.mark.profile
3225    def test_write(self) -> None:
3226        """
3227        | Description          | Try to write to different memory locations                       |
3228        | :------------------- | :--------------------------------------------------------------- |
3229        | GitHub Issue         | turnaroundfactor/BMS-HW-Test#379                            </br>\
3230                                 turnaroundfactor/BMS-HW-Test#380                                 |
3231        | Instructions         | 1. Request memory write                                     </br>\
3232                                 2. Log any successful addresses                                  |
3233        | Estimated Duration   | 85 seconds                                                       |
3234        """
3235
3236        _GENERATED_PROFILE.write_addresses = memory_test(Modes.WRITE)
3237
3238    def test_comparison(self) -> None:
3239        """
3240        | Description          | Compare write results                                            |
3241        | :------------------- | :--------------------------------------------------------------- |
3242        | GitHub Issue         | turnaroundfactor/BMS-HW-Test#379                            </br>\
3243                                 turnaroundfactor/BMS-HW-Test#380                                 |
3244        | Instructions         | Confirm no new memory addresses are writable                     |
3245        | Pass / Fail Criteria | Pass if no new writable memory addresses                         |
3246        | Estimated Duration   | 1 second                                                         |
3247        """
3248
3249        if hasattr(_GENERATED_PROFILE, "write_addresses") and hasattr(_COMPARISON_PROFILE, "write_addresses"):
3250            new_addresses = set(_GENERATED_PROFILE.write_addresses) - set(_COMPARISON_PROFILE.write_addresses)
3251            if new_addresses:
3252                message = (
3253                    f"{len(new_addresses)} Different writable memory address(es) found: "
3254                    f"{', '.join(map(str, new_addresses))}"
3255                )
3256                logger.write_failure_to_html_report(message)
3257                pytest.fail(message)
3258
3259            logger.write_result_to_html_report("Profiles had matching writable memory address responses")
3260
3261        else:
3262            message = "Nothing to compare. The profile test may have failed or been skipped"
3263            logger.write_result_to_html_report(message)
3264            pytest.skip(message)
3265
3266
3267class TestMemoryErase:
3268    """This will test the erase capability of the memory"""
3269
3270    @pytest.mark.profile
3271    def test_erase(self) -> None:
3272        """
3273        | Description          | Try to erase different memory locations                          |
3274        | :------------------- | :--------------------------------------------------------------- |
3275        | GitHub Issue         | turnaroundfactor/BMS-HW-Test#379                            </br>\
3276                                 turnaroundfactor/BMS-HW-Test#380                                 |
3277        | Instructions         | 1. Request memory erase                                     </br>\
3278                                 2. Log any successful addresses                                  |
3279        | Estimated Duration   | 85 seconds                                                       |
3280        """
3281
3282        _GENERATED_PROFILE.erase_addresses = memory_test(Modes.ERASE)
3283
3284    def test_comparison(self) -> None:
3285        """
3286        | Description          | Compare erase results                                            |
3287        | :------------------- | :--------------------------------------------------------------- |
3288        | GitHub Issue         | turnaroundfactor/BMS-HW-Test#379                            </br>\
3289                                 turnaroundfactor/BMS-HW-Test#380                                 |
3290        | Instructions         | Confirm no new memory addresses are erasable                     |
3291        | Pass / Fail Criteria | Pass if no new erasable memory addresses                         |
3292        | Estimated Duration   | 1 second                                                         |
3293        """
3294
3295        if hasattr(_GENERATED_PROFILE, "erase_addresses") and hasattr(_COMPARISON_PROFILE, "erase_addresses"):
3296            new_addresses = set(_GENERATED_PROFILE.erase_addresses) - set(_COMPARISON_PROFILE.erase_addresses)
3297            if new_addresses:
3298                message = (
3299                    f"{len(new_addresses)} Different erasable memory address(es) found: "
3300                    f"{', '.join(map(str, new_addresses))}"
3301                )
3302                logger.write_failure_to_html_report(message)
3303                pytest.fail(message)
3304
3305            logger.write_result_to_html_report("Profiles had matching erasable memory address responses")
3306
3307        else:
3308            message = "Nothing to compare. The profile test may have failed or been skipped"
3309            logger.write_result_to_html_report(message)
3310            pytest.skip(message)
3311
3312
3313class TestMemoryBootLoad:
3314    """This will test the boot load capability of the memory"""
3315
3316    @pytest.mark.profile
3317    def test_boot(self) -> None:
3318        """
3319        | Description          | Try to boot load from different memory locations                 |
3320        | :------------------- | :--------------------------------------------------------------- |
3321        | GitHub Issue         | turnaroundfactor/BMS-HW-Test#381                            </br>\
3322                                 turnaroundfactor/BMS-HW-Test#382                                 |
3323        | Instructions         | 1. Request memory boot load                                 </br>\
3324                                 2. Log any successful addresses                                  |
3325        | Estimated Duration   | 85 seconds                                                       |
3326        """
3327
3328        _GENERATED_PROFILE.boot_addresses = memory_test(Modes.BOOT)
3329
3330    def test_comparison(self) -> None:
3331        """
3332        | Description          | Compare boot load results                                        |
3333        | :------------------- | :--------------------------------------------------------------- |
3334        | GitHub Issue         | turnaroundfactor/BMS-HW-Test#381                            </br>\
3335                                 turnaroundfactor/BMS-HW-Test#382                                 |
3336        | Instructions         | Confirm no new memory addresses are boot load capable            |
3337        | Pass / Fail Criteria | Pass if no new boot load memory addresses                        |
3338        | Estimated Duration   | 1 second                                                         |
3339        """
3340
3341        if hasattr(_GENERATED_PROFILE, "boot_addresses") and hasattr(_COMPARISON_PROFILE, "boot_addresses"):
3342            new_addresses = set(_GENERATED_PROFILE.boot_addresses) - set(_COMPARISON_PROFILE.boot_addresses)
3343            if new_addresses:
3344                message = (
3345                    f"{len(new_addresses)} Different boot memory address(es) found: "
3346                    f"{', '.join(map(str, new_addresses))}"
3347                )
3348                logger.write_failure_to_html_report(message)
3349                pytest.fail(message)
3350
3351            logger.write_result_to_html_report("Profiles had matching boot memory address responses")
3352
3353        else:
3354            message = "Nothing to compare. The profile test may have failed or been skipped"
3355            logger.write_result_to_html_report(message)
3356            pytest.skip(message)
BATTERY_CHANNEL = 'can0'

Channel identification. Expected type is backend dependent.

HARD_RESET = False

Whether to use a soft or hard reset before each test.

PROFILE = ''

The name of the profile to compare against. If this is not provided, a profile json will be generated.

FULL_MEMORY_TESTS = False

Short or long memory tests.

LOG_PATH = PosixPath('/opt/hostedtoolcache/Python/3.12.7/x64/lib/python3.12/site-packages/hitl_tester/logs')

Path for profile logs.

class ManufacturerID(enum.IntEnum):
79class ManufacturerID(IntEnum):
80    """Enum to hold Manufacturer IDs."""
81
82    SAFT = 269  # NOTE: unused
83    BRENTRONICS = 822

Enum to hold Manufacturer IDs.

SAFT = <ManufacturerID.SAFT: 269>
BRENTRONICS = <ManufacturerID.BRENTRONICS: 822>
Inherited Members
enum.Enum
name
value
builtins.int
conjugate
bit_length
bit_count
to_bytes
from_bytes
as_integer_ratio
is_integer
real
imag
numerator
denominator
class Modes(builtins.float, enum.Enum):
86class Modes(float, Enum):
87    """Enum for Command modes"""
88
89    ERASE = 0
90    READ = 1
91    WRITE = 2
92    BOOT = 6
93    EDCP = 7

Enum for Command modes

ERASE = <Modes.ERASE: 0.0>
READ = <Modes.READ: 1.0>
WRITE = <Modes.WRITE: 2.0>
BOOT = <Modes.BOOT: 6.0>
EDCP = <Modes.EDCP: 7.0>
Inherited Members
enum.Enum
name
value
builtins.float
conjugate
as_integer_ratio
fromhex
hex
is_integer
real
imag
class BadStates(enum.Enum):
 96class BadStates(Enum):
 97    """Enum for Bad Test Response States"""
 98
 99    NO_RESPONSE = 0
100    WRONG_PGN = 1
101    NACK = 2
102    WRONG_PACKETS = 3
103    INVALID_RESPONSE = 4
104    SKIPPED_TEST = 5

Enum for Bad Test Response States

NO_RESPONSE = <BadStates.NO_RESPONSE: 0>
WRONG_PGN = <BadStates.WRONG_PGN: 1>
NACK = <BadStates.NACK: 2>
WRONG_PACKETS = <BadStates.WRONG_PACKETS: 3>
INVALID_RESPONSE = <BadStates.INVALID_RESPONSE: 4>
SKIPPED_TEST = <BadStates.SKIPPED_TEST: 5>
Inherited Members
enum.Enum
name
value
class Errors:
107class Errors:
108    """Holds different error messages"""
109
110    @staticmethod
111    def timeout() -> None:
112        """Prints message when connection times out"""
113        message = "Could not locate 6T"
114        logger.write_critical_to_report(message)
115        pytest.exit(message)
116
117    @staticmethod
118    def unexpected_packet(expected: str, frame: CANFrame) -> None:
119        """Prints message when unexpected packet is received"""
120        logger.write_warning_to_report(f"Expected {expected}, got {frame.pgn.short_name}")
121
122    @staticmethod
123    def no_packet(expected: str) -> None:
124        """Prints message when no packet is received"""
125        logger.write_warning_to_report(f"Expected {expected}, got None")

Holds different error messages

@staticmethod
def timeout() -> None:
110    @staticmethod
111    def timeout() -> None:
112        """Prints message when connection times out"""
113        message = "Could not locate 6T"
114        logger.write_critical_to_report(message)
115        pytest.exit(message)

Prints message when connection times out

@staticmethod
def unexpected_packet( expected: str, frame: hitl_tester.modules.cyber_6t.canbus.CANFrame) -> None:
117    @staticmethod
118    def unexpected_packet(expected: str, frame: CANFrame) -> None:
119        """Prints message when unexpected packet is received"""
120        logger.write_warning_to_report(f"Expected {expected}, got {frame.pgn.short_name}")

Prints message when unexpected packet is received

@staticmethod
def no_packet(expected: str) -> None:
122    @staticmethod
123    def no_packet(expected: str) -> None:
124        """Prints message when no packet is received"""
125        logger.write_warning_to_report(f"Expected {expected}, got None")

Prints message when no packet is received

def cmp( a: float | str, sign: Literal['<', '<=', '>', '>=', '=='], b: float | str, unit_a: str = '', unit_b: str = '', form: str = '') -> str:
131def cmp(
132    a: float | str,
133    sign: Literal["<", "<=", ">", ">=", "=="],
134    b: float | str,
135    unit_a: str = "",
136    unit_b: str = "",
137    form: str = "",
138) -> str:
139    """Generate a formatted string based on a comparison."""
140    if not unit_b:
141        unit_b = unit_a
142
143    if isinstance(a, str) or isinstance(b, str):
144        return f"{a:{form}}{unit_a} {('≠', '=')[a == b]} {b:{form}}{unit_b}"
145
146    sign_str = {
147        "<": ("≮", "<")[a < b],
148        "<=": ("≰", "≤")[a <= b],
149        ">": ("≯", ">")[a > b],
150        ">=": ("≱", "≥")[a >= b],
151        "==": ("≠", "=")[a == b],
152    }
153
154    return f"{a:{form}}{unit_a} {sign_str[sign]} {b:{form}}{unit_b}"

Generate a formatted string based on a comparison.

@pytest.fixture(scope='session', autouse=True)
def generate_profile():
157@pytest.fixture(scope="session", autouse=True)
158def generate_profile():
159    """Generate a json object for the profile after all tests complete."""
160    global _COMPARISON_PROFILE
161
162    if PROFILE:
163        _COMPARISON_PROFILE = SimpleNamespace(**json.loads(Path(PROFILE).read_text(encoding="UTF-8")))
164
165    yield  # Begin session
166
167    if not PROFILE:
168        try:
169            manufacturer_name = "".join(filter(str.isalnum, _GENERATED_PROFILE.manufacturer_name))
170            battery_id = f"{_GENERATED_PROFILE.id:06X}"
171        except AttributeError:
172            logger.write_error_to_report("Could not write profile: No name or ID")
173        else:
174            profile_path = (Path(LOG_PATH) / "profile") / f"{manufacturer_name}_6T_{battery_id}.json"
175            profile_path.parent.mkdir(parents=True, exist_ok=True)
176            profile_path.write_text(json.dumps(_GENERATED_PROFILE, default=vars, indent=4), encoding="UTF-8")
177            logger.write_info_to_report(f"New Profile: {profile_path.absolute()}")

Generate a json object for the profile after all tests complete.

@pytest.fixture(scope='class', autouse=True)
def reset_test_environment():
180@pytest.fixture(scope="class", autouse=True)
181def reset_test_environment():
182    """Before each test class, reset the 6T."""
183
184    try:
185        power_cycle_frame = CANFrame(
186            destination_address=_GENERATED_PROFILE.address,
187            pgn=PGN["PropA"],
188            data=[0, 0, 1, 1, 1, not HARD_RESET, 1, 3, 0, -1],
189        )
190        maintenance_mode_frame = CANFrame(
191            destination_address=_GENERATED_PROFILE.address,
192            pgn=PGN["PropA"],
193            data=[0, 0, 1, 1, 1, 3, 1, 3, 0, -1],
194        )
195        factory_reset_frame = CANFrame(
196            destination_address=_GENERATED_PROFILE.address,
197            pgn=PGN["PropA"],
198            data=[1, 0, 0, -1, 3, 0xF, 3, 0, 0x1F, -1],
199        )
200        with CANBus(BATTERY_CHANNEL) as bus:
201            logger.write_info_to_report("Power-Cycling 6T")
202            bus.process_call(power_cycle_frame)
203            time.sleep(10)
204            logger.write_info_to_report("Entering maintenance mode")
205            bus.process_call(maintenance_mode_frame)
206            logger.write_info_to_report("Factory resetting 6T")
207            bus.process_call(factory_reset_frame)
208            time.sleep(10)
209    except AttributeError:  # Battery has not yet been found
210        return

Before each test class, reset the 6T.

@pytest.fixture(autouse=True)
def check_mode(request):
213@pytest.fixture(autouse=True)
214def check_mode(request):
215    """Skip comparison tests and output a profile if no comparison file is provided."""
216    if not PROFILE and request.node.get_closest_marker("profile") is None:
217        pytest.skip('Mode is "Profile Generation".')

Skip comparison tests and output a profile if no comparison file is provided.

class TestLocate6T:
220class TestLocate6T:
221    """Scan for 6T battery."""
222
223    MAX_ATTEMPTS = 12  # 2 minutes worth of attempts
224
225    @pytest.mark.profile
226    def test_profile(self) -> None:
227        """
228        | Description          | Scan the bus for devices                                         |
229        | :------------------- | :--------------------------------------------------------------- |
230        | GitHub Issue         | turnaroundfactor/BMS-HW-Test#396                                 |
231        | Instructions         | 1. Request the names of everyone on the bus                 </br>\
232                                 2. Use the response data for all communication                   |
233        | Estimated Duration   | 1 second                                                         |
234        """
235        name_request = CANFrame(pgn=PGN["Request"], data=[PGN["Address Claimed"].id])
236        with CANBus(BATTERY_CHANNEL) as bus:
237            for attempt in range(self.MAX_ATTEMPTS):
238                with suppress(IndexError):
239                    if name_frame := bus.process_call(name_request):
240                        # Save responses to profile
241                        _GENERATED_PROFILE.id = int(name_frame.data[0])
242                        _GENERATED_PROFILE.manufacturer_code = name_frame.data[1]
243                        if (mfg_name := spn_types.manufacturer_id(name_frame.data[1])) == "Reserved":
244                            mfg_name = f"Unknown ({name_frame.data[1]})"
245                        _GENERATED_PROFILE.manufacturer_name = mfg_name
246                        _GENERATED_PROFILE.address = name_frame.source_address
247                        break
248                logger.write_warning_to_html_report(f"Failed to locate 6T. Retrying, attempt {attempt + 1}")
249                time.sleep(10)
250            else:
251                message = "Could not locate 6T"
252                logger.write_warning_to_html_report(message)
253                pytest.exit(message)
254
255        # Log results
256        printable_id = "".join(
257            chr(i) if chr(i).isprintable() else "." for i in _GENERATED_PROFILE.id.to_bytes(3, byteorder="big")
258        )
259        logger.write_result_to_html_report(
260            f"Found {_GENERATED_PROFILE.manufacturer_name} 6T at address {_GENERATED_PROFILE.address} "
261            f"(ID: {_GENERATED_PROFILE.id:06X}, ASCII ID: {printable_id})"
262        )

Scan for 6T battery.

MAX_ATTEMPTS = 12
@pytest.mark.profile
def test_profile(self) -> None:
225    @pytest.mark.profile
226    def test_profile(self) -> None:
227        """
228        | Description          | Scan the bus for devices                                         |
229        | :------------------- | :--------------------------------------------------------------- |
230        | GitHub Issue         | turnaroundfactor/BMS-HW-Test#396                                 |
231        | Instructions         | 1. Request the names of everyone on the bus                 </br>\
232                                 2. Use the response data for all communication                   |
233        | Estimated Duration   | 1 second                                                         |
234        """
235        name_request = CANFrame(pgn=PGN["Request"], data=[PGN["Address Claimed"].id])
236        with CANBus(BATTERY_CHANNEL) as bus:
237            for attempt in range(self.MAX_ATTEMPTS):
238                with suppress(IndexError):
239                    if name_frame := bus.process_call(name_request):
240                        # Save responses to profile
241                        _GENERATED_PROFILE.id = int(name_frame.data[0])
242                        _GENERATED_PROFILE.manufacturer_code = name_frame.data[1]
243                        if (mfg_name := spn_types.manufacturer_id(name_frame.data[1])) == "Reserved":
244                            mfg_name = f"Unknown ({name_frame.data[1]})"
245                        _GENERATED_PROFILE.manufacturer_name = mfg_name
246                        _GENERATED_PROFILE.address = name_frame.source_address
247                        break
248                logger.write_warning_to_html_report(f"Failed to locate 6T. Retrying, attempt {attempt + 1}")
249                time.sleep(10)
250            else:
251                message = "Could not locate 6T"
252                logger.write_warning_to_html_report(message)
253                pytest.exit(message)
254
255        # Log results
256        printable_id = "".join(
257            chr(i) if chr(i).isprintable() else "." for i in _GENERATED_PROFILE.id.to_bytes(3, byteorder="big")
258        )
259        logger.write_result_to_html_report(
260            f"Found {_GENERATED_PROFILE.manufacturer_name} 6T at address {_GENERATED_PROFILE.address} "
261            f"(ID: {_GENERATED_PROFILE.id:06X}, ASCII ID: {printable_id})"
262        )
Description Scan the bus for devices
GitHub Issue turnaroundfactor/BMS-HW-Test#396
Instructions 1. Request the names of everyone on the bus
2. Use the response data for all communication
Estimated Duration 1 second
class TestProprietaryCommands:
265class TestProprietaryCommands:
266    """Scan for proprietary commands"""
267
268    @pytest.mark.profile
269    def test_profile(self) -> None:
270        """
271        | Description          | Scan for proprietary commands                                    |
272        | :------------------- | :--------------------------------------------------------------- |
273        | GitHub Issue         | turnaroundfactor/BMS-HW-Test#293                                 |
274        | Instructions         | 1. Request data from all 512 proprietary commands           </br>\
275                                 2. Log any responses                                             |
276        | Estimated Duration   | 47 seconds                                                       |
277        | Note                 | Proprietary commands are unique to each battery type or          \
278                                 manufacturer, which makes them useful for fingerprinting.        \
279                                 Especially if undocumented.                                      |
280        """
281        ack_responses: dict[int, CANFrame] = {}  # address: response
282        proprietary_request = CANFrame(destination_address=_GENERATED_PROFILE.address, pgn=PGN["RQST"], data=[0])
283
284        with CANBus(BATTERY_CHANNEL) as bus:
285            # Check all proprietary address ranges (0xEF00-FF00) and (0xFF00, 0x10000)
286            for address in itertools.chain(range(0xEF00, 0xF000), range(0xFF00, 0x10000)):
287                proprietary_request.data = [address]
288                if response_frame := bus.process_call(proprietary_request):  # Log if response is not NACK
289                    if not (response_frame.pgn.id == PGN["Acknowledgement"].id and response_frame.data[0] == 1):
290                        ack_responses[address] = response_frame
291                        logger.write_info_to_report(f"Got response at address {address:04X}: {response_frame}")
292
293        # Check conformity to MIL-PRF commands (EF00, FF00-08)
294        # TODO(JA): Also check format (or should that be a separate test?)
295        milprf_commands = set(range(0xFF00, 0xFF09))
296        if milprf := milprf_commands - set(ack_responses):
297            logger.write_result_to_html_report(f"Missing MIL-PRF commands: {', '.join(map(str, milprf))}")
298
299        # Save responses to profile
300        logger.write_result_to_html_report(
301            f"Found {len(ack_responses)} commands at: {', '.join(map(lambda i: f'{i:X}', ack_responses))}"
302        )
303        _GENERATED_PROFILE.proprietary_commands = ack_responses
304
305    def test_comparison(self) -> None:
306        """
307        | Description          | Compare proprietary commands                                    |
308        | :------------------- | :--------------------------------------------------------------- |
309        | GitHub Issue         | turnaroundfactor/BMS-HW-Test#293                                 |
310        | Instructions         | 1. Confirm no new commands exit and no commands are missing </br>\
311                                 2. Check contents of MIL-PRF range is valid                      |
312        | Pass / Fail Criteria | Pass if we found all commands, and saw no new or malformed ones  |
313        | Estimated Duration   | 1 second                                                         |
314        """
315
316        comparison_commands = set(map(int, _COMPARISON_PROFILE.proprietary_commands))  # Converted to str by json
317        missing_commands = comparison_commands - set(_GENERATED_PROFILE.proprietary_commands)
318        new_commands = set(_GENERATED_PROFILE.proprietary_commands) - comparison_commands
319
320        # Check results
321        logger.write_result_to_html_report(f"Missing: {len(missing_commands)}, New: {len(new_commands)}")
322        if missing_commands or new_commands:
323            logger.write_warning_to_html_report(f"Missing commands: {', '.join(map(str, missing_commands))}")
324            logger.write_warning_to_html_report(f"New Commands: {', '.join(map(str, new_commands))}")
325            logger.write_failure_to_html_report("Missing or new commands present.")
326            pytest.fail("Missing or new commands present.")

Scan for proprietary commands

@pytest.mark.profile
def test_profile(self) -> None:
268    @pytest.mark.profile
269    def test_profile(self) -> None:
270        """
271        | Description          | Scan for proprietary commands                                    |
272        | :------------------- | :--------------------------------------------------------------- |
273        | GitHub Issue         | turnaroundfactor/BMS-HW-Test#293                                 |
274        | Instructions         | 1. Request data from all 512 proprietary commands           </br>\
275                                 2. Log any responses                                             |
276        | Estimated Duration   | 47 seconds                                                       |
277        | Note                 | Proprietary commands are unique to each battery type or          \
278                                 manufacturer, which makes them useful for fingerprinting.        \
279                                 Especially if undocumented.                                      |
280        """
281        ack_responses: dict[int, CANFrame] = {}  # address: response
282        proprietary_request = CANFrame(destination_address=_GENERATED_PROFILE.address, pgn=PGN["RQST"], data=[0])
283
284        with CANBus(BATTERY_CHANNEL) as bus:
285            # Check all proprietary address ranges (0xEF00-FF00) and (0xFF00, 0x10000)
286            for address in itertools.chain(range(0xEF00, 0xF000), range(0xFF00, 0x10000)):
287                proprietary_request.data = [address]
288                if response_frame := bus.process_call(proprietary_request):  # Log if response is not NACK
289                    if not (response_frame.pgn.id == PGN["Acknowledgement"].id and response_frame.data[0] == 1):
290                        ack_responses[address] = response_frame
291                        logger.write_info_to_report(f"Got response at address {address:04X}: {response_frame}")
292
293        # Check conformity to MIL-PRF commands (EF00, FF00-08)
294        # TODO(JA): Also check format (or should that be a separate test?)
295        milprf_commands = set(range(0xFF00, 0xFF09))
296        if milprf := milprf_commands - set(ack_responses):
297            logger.write_result_to_html_report(f"Missing MIL-PRF commands: {', '.join(map(str, milprf))}")
298
299        # Save responses to profile
300        logger.write_result_to_html_report(
301            f"Found {len(ack_responses)} commands at: {', '.join(map(lambda i: f'{i:X}', ack_responses))}"
302        )
303        _GENERATED_PROFILE.proprietary_commands = ack_responses
Description Scan for proprietary commands
GitHub Issue turnaroundfactor/BMS-HW-Test#293
Instructions 1. Request data from all 512 proprietary commands
2. Log any responses
Estimated Duration 47 seconds
Note Proprietary commands are unique to each battery type or manufacturer, which makes them useful for fingerprinting. Especially if undocumented.
def test_comparison(self) -> None:
305    def test_comparison(self) -> None:
306        """
307        | Description          | Compare proprietary commands                                    |
308        | :------------------- | :--------------------------------------------------------------- |
309        | GitHub Issue         | turnaroundfactor/BMS-HW-Test#293                                 |
310        | Instructions         | 1. Confirm no new commands exit and no commands are missing </br>\
311                                 2. Check contents of MIL-PRF range is valid                      |
312        | Pass / Fail Criteria | Pass if we found all commands, and saw no new or malformed ones  |
313        | Estimated Duration   | 1 second                                                         |
314        """
315
316        comparison_commands = set(map(int, _COMPARISON_PROFILE.proprietary_commands))  # Converted to str by json
317        missing_commands = comparison_commands - set(_GENERATED_PROFILE.proprietary_commands)
318        new_commands = set(_GENERATED_PROFILE.proprietary_commands) - comparison_commands
319
320        # Check results
321        logger.write_result_to_html_report(f"Missing: {len(missing_commands)}, New: {len(new_commands)}")
322        if missing_commands or new_commands:
323            logger.write_warning_to_html_report(f"Missing commands: {', '.join(map(str, missing_commands))}")
324            logger.write_warning_to_html_report(f"New Commands: {', '.join(map(str, new_commands))}")
325            logger.write_failure_to_html_report("Missing or new commands present.")
326            pytest.fail("Missing or new commands present.")
Description Compare proprietary commands
GitHub Issue turnaroundfactor/BMS-HW-Test#293
Instructions 1. Confirm no new commands exit and no commands are missing
2. Check contents of MIL-PRF range is valid
Pass / Fail Criteria Pass if we found all commands, and saw no new or malformed ones
Estimated Duration 1 second
class TestNameInformation:
329class TestNameInformation:
330    """Check Unique ID (Identity Number) field in Address Claimed message"""
331
332    @pytest.mark.profile
333    def test_profile(self) -> None:
334        """
335        | Description          | Confirm information in NAME (address claimed) matches            \
336                                 expected value.                                                  |
337        | :------------------- | :--------------------------------------------------------------- |
338        | GitHub Issue         | turnaroundfactor/BMS-HW-Test#396                                 |
339        | Instructions         | 1. Request Address Claimed data                             </br>\
340                                 2. Log returned values                                      </br>\
341                                 3. Validate if values meet spec requirements                     |
342        | Estimated Duration   | 21 seconds                                                       |
343        """
344
345        name_values = {}
346
347        name_request = CANFrame(pgn=PGN["Request"], data=[PGN["Address Claimed"].id])
348        with CANBus(BATTERY_CHANNEL) as bus:
349            if name_frame := bus.process_call(name_request):
350                name_values["identity_number"] = int(name_frame.data[0])
351
352                if name_values["identity_number"] != _GENERATED_PROFILE.id:
353                    message = f"Identity Number {name_values} does not match Profile ID: {_GENERATED_PROFILE.id}"
354                    logger.write_warning_to_html_report(message)
355
356                name_values["manufacturer_code"] = name_frame.data[1]
357                name_values["ecu_instance"] = name_frame.data[2]
358                name_values["function_instance"] = name_frame.data[3]
359                name_values["function"] = name_frame.data[4]
360                name_values["reserved"] = name_frame.data[5]
361                name_values["vehicle_system"] = name_frame.data[6]
362                name_values["vehicle_system_instance"] = name_frame.data[7]
363                name_values["industry_group"] = name_frame.data[8]
364                name_values["arbitrary_address_capable"] = name_frame.data[9]
365
366                for spn, elem in zip(PGN["Address Claimed"].data_field, name_frame.data):
367                    description = f"({desc})" if (desc := spn.data_type(elem)) else ""
368                    logger.write_info_to_report(f"{spn.name}: {elem} {description}")
369
370                    if spn.name in ("Manufacturer Code", "Reserved"):
371                        continue
372
373                    high_range = 0
374                    if spn.name == "Identity Number":
375                        high_range = 2097151
376
377                    if spn.name == "ECU Instance":
378                        high_range = 7
379
380                    if spn.name == "Function Instance":
381                        high_range = 31
382
383                    if spn.name == "Function":
384                        high_range = 254
385
386                    if spn.name == "Vehicle System Instance":
387                        high_range = 15
388
389                    if spn.name == "Industry Group":
390                        high_range = 7
391
392                    if spn.name == "Arbitrary Address Capable":
393                        high_range = 1
394
395                    if spn.name == "Vehicle System":
396                        if (
397                            name_values["vehicle_system"] != 127
398                            and name_values["vehicle_system"] != 0
399                            and name_values["vehicle_system"] != name_values["vehicle_system_instance"]
400                        ):
401                            logger.write_warning_to_html_report(
402                                f"Vehicle System {elem} is not 127, 0, or the same value as " f"Vehicle System Instance"
403                            )
404                            _GENERATED_PROFILE.name_violation = "invalid"
405                        else:
406                            _GENERATED_PROFILE.name_violation = "valid"
407
408                    else:
409                        if not 0 <= elem <= high_range:
410                            logger.write_warning_to_html_report(
411                                f"{spn.name}: {cmp(elem, '>=', 0)} and " f"{cmp(elem, '<=', high_range)}"
412                            )
413                            _GENERATED_PROFILE.name_violation = "invalid"
414                        else:
415                            _GENERATED_PROFILE.name_violation = "valid"
416
417                _GENERATED_PROFILE.name_properties = name_values
418
419            else:
420                message = f"No response to PGN {PGN['Address Claimed', [32]].id} (Address Claimed)"
421                logger.write_warning_to_html_report(message)
422                _GENERATED_PROFILE.name_field = BadStates.NO_RESPONSE.name
423                _GENERATED_PROFILE.name_violation = "invalid"
424                return
425
426        logger.write_result_to_html_report(
427            f"Found Identity Number: {_GENERATED_PROFILE.id} (0x{_GENERATED_PROFILE.id:06x}) "
428            f"for {_GENERATED_PROFILE.manufacturer_name} 6T at address {_GENERATED_PROFILE.address}"
429        )
430
431    def test_comparison(self) -> None:
432        """
433        | Description          | Compare identity numbers                                         |
434        | :------------------- | :--------------------------------------------------------------- |
435        | GitHub Issue         | turnaroundfactor/BMS-HW-Test#396                                 |
436        | Instructions         | 1. Confirm identity numbers are unique for each battery          |
437        | Pass / Fail Criteria | Pass if identity numbers are unique for separate batteries       |
438        | Estimated Duration   | 1 second                                                         |
439        """
440        failed_test = False
441        if not hasattr(_GENERATED_PROFILE, "id") or not hasattr(_COMPARISON_PROFILE, "id"):
442            logger.write_result_to_html_report("Nothing to compare. The profile test may have failed or been skipped")
443            pytest.skip("Nothing to compare. The profile test may have failed or been skipped")
444
445        logger.write_result_to_html_report(
446            f"Generated ID: {_GENERATED_PROFILE.id}, " f"Comparison ID: {_COMPARISON_PROFILE.id}"
447        )
448        if _GENERATED_PROFILE.id == _COMPARISON_PROFILE.id:
449            logger.write_warning_to_html_report("Generated IDs are not unique values between batteries.")
450        else:
451            logger.write_result_to_html_report("Generated IDs are unique values")
452
453        if not hasattr(_GENERATED_PROFILE, "name_violation") or not hasattr(_COMPARISON_PROFILE, "name_violation"):
454            logger.write_warning_to_html_report("Missing specification information")
455        else:
456            comparison = cmp(_GENERATED_PROFILE.name_violation, "==", _COMPARISON_PROFILE.name_violation)
457            if _GENERATED_PROFILE.name_violation != _COMPARISON_PROFILE.name_violation:
458                logger.write_failure_to_html_report(f"Specification behavior mismatched: {comparison}")
459                failed_test = True
460            else:
461                logger.write_result_to_html_report(f"Specification behavior matched: {comparison}")
462
463        if failed_test:
464            message = "Comparisons did not meet expectations"
465            logger.write_failure_to_html_report(message)
466            pytest.fail(message)

Check Unique ID (Identity Number) field in Address Claimed message

@pytest.mark.profile
def test_profile(self) -> None:
332    @pytest.mark.profile
333    def test_profile(self) -> None:
334        """
335        | Description          | Confirm information in NAME (address claimed) matches            \
336                                 expected value.                                                  |
337        | :------------------- | :--------------------------------------------------------------- |
338        | GitHub Issue         | turnaroundfactor/BMS-HW-Test#396                                 |
339        | Instructions         | 1. Request Address Claimed data                             </br>\
340                                 2. Log returned values                                      </br>\
341                                 3. Validate if values meet spec requirements                     |
342        | Estimated Duration   | 21 seconds                                                       |
343        """
344
345        name_values = {}
346
347        name_request = CANFrame(pgn=PGN["Request"], data=[PGN["Address Claimed"].id])
348        with CANBus(BATTERY_CHANNEL) as bus:
349            if name_frame := bus.process_call(name_request):
350                name_values["identity_number"] = int(name_frame.data[0])
351
352                if name_values["identity_number"] != _GENERATED_PROFILE.id:
353                    message = f"Identity Number {name_values} does not match Profile ID: {_GENERATED_PROFILE.id}"
354                    logger.write_warning_to_html_report(message)
355
356                name_values["manufacturer_code"] = name_frame.data[1]
357                name_values["ecu_instance"] = name_frame.data[2]
358                name_values["function_instance"] = name_frame.data[3]
359                name_values["function"] = name_frame.data[4]
360                name_values["reserved"] = name_frame.data[5]
361                name_values["vehicle_system"] = name_frame.data[6]
362                name_values["vehicle_system_instance"] = name_frame.data[7]
363                name_values["industry_group"] = name_frame.data[8]
364                name_values["arbitrary_address_capable"] = name_frame.data[9]
365
366                for spn, elem in zip(PGN["Address Claimed"].data_field, name_frame.data):
367                    description = f"({desc})" if (desc := spn.data_type(elem)) else ""
368                    logger.write_info_to_report(f"{spn.name}: {elem} {description}")
369
370                    if spn.name in ("Manufacturer Code", "Reserved"):
371                        continue
372
373                    high_range = 0
374                    if spn.name == "Identity Number":
375                        high_range = 2097151
376
377                    if spn.name == "ECU Instance":
378                        high_range = 7
379
380                    if spn.name == "Function Instance":
381                        high_range = 31
382
383                    if spn.name == "Function":
384                        high_range = 254
385
386                    if spn.name == "Vehicle System Instance":
387                        high_range = 15
388
389                    if spn.name == "Industry Group":
390                        high_range = 7
391
392                    if spn.name == "Arbitrary Address Capable":
393                        high_range = 1
394
395                    if spn.name == "Vehicle System":
396                        if (
397                            name_values["vehicle_system"] != 127
398                            and name_values["vehicle_system"] != 0
399                            and name_values["vehicle_system"] != name_values["vehicle_system_instance"]
400                        ):
401                            logger.write_warning_to_html_report(
402                                f"Vehicle System {elem} is not 127, 0, or the same value as " f"Vehicle System Instance"
403                            )
404                            _GENERATED_PROFILE.name_violation = "invalid"
405                        else:
406                            _GENERATED_PROFILE.name_violation = "valid"
407
408                    else:
409                        if not 0 <= elem <= high_range:
410                            logger.write_warning_to_html_report(
411                                f"{spn.name}: {cmp(elem, '>=', 0)} and " f"{cmp(elem, '<=', high_range)}"
412                            )
413                            _GENERATED_PROFILE.name_violation = "invalid"
414                        else:
415                            _GENERATED_PROFILE.name_violation = "valid"
416
417                _GENERATED_PROFILE.name_properties = name_values
418
419            else:
420                message = f"No response to PGN {PGN['Address Claimed', [32]].id} (Address Claimed)"
421                logger.write_warning_to_html_report(message)
422                _GENERATED_PROFILE.name_field = BadStates.NO_RESPONSE.name
423                _GENERATED_PROFILE.name_violation = "invalid"
424                return
425
426        logger.write_result_to_html_report(
427            f"Found Identity Number: {_GENERATED_PROFILE.id} (0x{_GENERATED_PROFILE.id:06x}) "
428            f"for {_GENERATED_PROFILE.manufacturer_name} 6T at address {_GENERATED_PROFILE.address}"
429        )
Description Confirm information in NAME (address claimed) matches expected value.
GitHub Issue turnaroundfactor/BMS-HW-Test#396
Instructions 1. Request Address Claimed data
2. Log returned values
3. Validate if values meet spec requirements
Estimated Duration 21 seconds
def test_comparison(self) -> None:
431    def test_comparison(self) -> None:
432        """
433        | Description          | Compare identity numbers                                         |
434        | :------------------- | :--------------------------------------------------------------- |
435        | GitHub Issue         | turnaroundfactor/BMS-HW-Test#396                                 |
436        | Instructions         | 1. Confirm identity numbers are unique for each battery          |
437        | Pass / Fail Criteria | Pass if identity numbers are unique for separate batteries       |
438        | Estimated Duration   | 1 second                                                         |
439        """
440        failed_test = False
441        if not hasattr(_GENERATED_PROFILE, "id") or not hasattr(_COMPARISON_PROFILE, "id"):
442            logger.write_result_to_html_report("Nothing to compare. The profile test may have failed or been skipped")
443            pytest.skip("Nothing to compare. The profile test may have failed or been skipped")
444
445        logger.write_result_to_html_report(
446            f"Generated ID: {_GENERATED_PROFILE.id}, " f"Comparison ID: {_COMPARISON_PROFILE.id}"
447        )
448        if _GENERATED_PROFILE.id == _COMPARISON_PROFILE.id:
449            logger.write_warning_to_html_report("Generated IDs are not unique values between batteries.")
450        else:
451            logger.write_result_to_html_report("Generated IDs are unique values")
452
453        if not hasattr(_GENERATED_PROFILE, "name_violation") or not hasattr(_COMPARISON_PROFILE, "name_violation"):
454            logger.write_warning_to_html_report("Missing specification information")
455        else:
456            comparison = cmp(_GENERATED_PROFILE.name_violation, "==", _COMPARISON_PROFILE.name_violation)
457            if _GENERATED_PROFILE.name_violation != _COMPARISON_PROFILE.name_violation:
458                logger.write_failure_to_html_report(f"Specification behavior mismatched: {comparison}")
459                failed_test = True
460            else:
461                logger.write_result_to_html_report(f"Specification behavior matched: {comparison}")
462
463        if failed_test:
464            message = "Comparisons did not meet expectations"
465            logger.write_failure_to_html_report(message)
466            pytest.fail(message)
Description Compare identity numbers
GitHub Issue turnaroundfactor/BMS-HW-Test#396
Instructions 1. Confirm identity numbers are unique for each battery
Pass / Fail Criteria Pass if identity numbers are unique for separate batteries
Estimated Duration 1 second
class TestSoftwareIdentificationInformation:
469class TestSoftwareIdentificationInformation:
470    """Retrieve & Validate Software Identification Information"""
471
472    @pytest.mark.profile
473    def test_software_identification_information(self) -> None:
474        """
475        | Description          | Retrieve Software Identification                                 |
476        | :------------------- | :--------------------------------------------------------------- |
477        | GitHub Issue         | turnaroundfactor/BMS-HW-Test#394                                 |
478        | Instructions         | 1. Request Software Identification                          </br>\
479                                 2. Validate Software Identification                         </br>\
480                                 3. Log Response                                                  |
481        | Estimated Duration   | 21 seconds                                                       |
482        """
483
484        soft_request = CANFrame(pgn=PGN["RQST"], data=[0xFEDA])
485        soft_data = []
486        with CANBus(BATTERY_CHANNEL) as bus:
487            if soft_frame := bus.process_call(soft_request):
488                # Complete RTS
489                if len(soft_frame.data) < 3:
490                    message = (
491                        f"Unexpected byte response from PGN "
492                        f"{PGN['Software Identification', [32]].id} (Software Identification)"
493                    )
494                    _GENERATED_PROFILE.software_identification_information = BadStates.WRONG_PACKETS.name
495                    logger.write_warning_to_html_report(message)
496                    _GENERATED_PROFILE.software_identification_specification = "invalid"
497                    return
498                expected_bytes = int(soft_frame.data[1])
499                expected_packets = 0
500                if _GENERATED_PROFILE.manufacturer_code == ManufacturerID.BRENTRONICS:
501                    expected_packets = int(soft_frame.data[2] + 1)
502                else:
503                    expected_packets = int(soft_frame.data[2])
504
505                rts_pgn_id = int(soft_frame.data[-1])
506                cts_request = CANFrame(pgn=PGN["TP.CM"], data=[17, 0, 1, 0xFF, 0])
507                cts_request.data[1] = expected_packets
508                cts_request.data[-1] = rts_pgn_id
509
510                # Send CTS
511                bus.send_message(cts_request.message())
512
513                # Read & Store data from packets
514                for i in range(expected_packets):
515                    data_frame = bus.read_frame()
516                    if data_frame.pgn.id != 0xEB00:
517                        message = f"Expected data frame {i + 1}, got PGN {data_frame.pgn.id}"
518                        _GENERATED_PROFILE.software_identification_information = BadStates.WRONG_PACKETS.name
519                        _GENERATED_PROFILE.software_identification_specification = "invalid"
520                        logger.write_warning_to_html_report(message)
521                        return
522                    soft_data.append(hex(int(data_frame.data[1])))
523
524                if len(soft_data) != expected_packets:
525                    message = f"Expected {expected_packets} packets, got {len(soft_data)}"
526                    logger.write_warning_to_html_report(message)
527                    _GENERATED_PROFILE.software_identification_specification = "invalid"
528                    _GENERATED_PROFILE.software_identification_information = BadStates.WRONG_PACKETS.name
529                    return
530
531                # Send acknowledgement frame
532                end_acknowledge_frame = CANFrame(pgn=PGN["TP.CM"], data=[19, 0, 0, 0xFF, 0])
533                end_acknowledge_frame.data[1] = expected_bytes
534                end_acknowledge_frame.data[2] = expected_packets
535                end_acknowledge_frame.data[-1] = rts_pgn_id
536
537                bus.send_message(end_acknowledge_frame.message())
538            else:
539                message = f"No response for PGN {PGN['Software Identification', [32]].id} (Software Identification)"
540                logger.write_warning_to_html_report(message)
541                _GENERATED_PROFILE.software_identification_information = BadStates.NO_RESPONSE.name
542                _GENERATED_PROFILE.software_identification_specification = "invalid"
543                return
544
545        hex_string = "0x"
546        add_to_data = False
547        finish_adding = False
548
549        # Find Software Identification Number
550        for element in soft_data:
551            n = len(element) - 1
552            while n >= 1:
553                if add_to_data:
554                    if element[n - 1 : n + 1] == "2a":
555                        finish_adding = True
556                        break
557                    if element[n - 1 : n + 1] != "0x":
558                        hex_string += element[n - 1 : n + 1]
559                        n -= 2
560                    else:
561                        n -= 2
562                else:
563                    if element[n - 3 : n + 1] == "2a31" or element[n - 3 : n + 1] == "2a01":
564                        add_to_data = True
565                        n -= 4
566                    else:
567                        n -= 4
568            if finish_adding:
569                break
570
571        if len(hex_string) <= 2:
572            logger.write_warning_to_html_report(
573                f"Software Identification: {hex_string} was not found in expected format"
574            )
575            _GENERATED_PROFILE.software_identification_information = hex_string
576            _GENERATED_PROFILE.software_identification_specification = "invalid"
577            return
578
579        software_identification = spn_types.ascii_map(int(hex_string, 16))
580
581        if re.search(r"[0-9]{2}\.[0-9]{2}\.[a-z]{2}\.[a-z]{2,}", software_identification) is None:
582            message = f"Software Identification: {software_identification} was not in expected format: MM.II.mm.aa.ee"
583            logger.write_warning_to_html_report(message)
584            _GENERATED_PROFILE.software_identification_specification = "invalid"
585        else:
586            _GENERATED_PROFILE.software_identification_specification = "valid"
587
588        _GENERATED_PROFILE.software_identification = software_identification
589
590        logger.write_result_to_html_report(f"Software Identification: {software_identification}")
591
592    def test_comparison(self) -> None:
593        """
594        | Description          | Compare Software Identification values                           |
595        | :------------------- | :--------------------------------------------------------------- |
596        | GitHub Issue         | turnaroundfactor/BMS-HW-Test#394                                 |
597        | Instructions         | 1. Confirm software versions are the same for each profile       |
598        | Pass / Fail Criteria | Pass if identity numbers match for each profile                  |
599        | Estimated Duration   | 1 second                                                         |
600        """
601        failed_test = False
602        if not hasattr(_GENERATED_PROFILE, "software_identification") or not hasattr(
603            _COMPARISON_PROFILE, "software_identification"
604        ):
605            message = "Nothing to compare. The profile test may have failed or been skipped"
606            logger.write_result_to_html_report(message)
607            pytest.skip(message)
608
609        compare = cmp(_GENERATED_PROFILE.software_identification, "==", _COMPARISON_PROFILE.software_identification)
610        if _GENERATED_PROFILE.software_identification != _COMPARISON_PROFILE.software_identification:
611            logger.write_failure_to_html_report(f"Software Identification: {compare}")
612            failed_test = True
613        else:
614            logger.write_result_to_html_report(f"Software Identification: {compare}")
615
616        if not hasattr(_GENERATED_PROFILE, "software_identification_specification") or not hasattr(
617            _COMPARISON_PROFILE, "software_identification_specification"
618        ):
619            logger.write_warning_to_html_report("Missing specification information")
620        else:
621            comparison = cmp(
622                _GENERATED_PROFILE.software_identification_specification,
623                "==",
624                _COMPARISON_PROFILE.software_identification_specification,
625            )
626            if (
627                _GENERATED_PROFILE.software_identification_specification
628                != _COMPARISON_PROFILE.software_identification_specification
629            ):
630                logger.write_failure_to_html_report(f"Specification comparison failed: {comparison}")
631                failed_test = True
632            else:
633                logger.write_result_to_html_report(f"Specification comparison passed: {comparison}")
634
635        if failed_test:
636            message = "Comparisons did not match in profiles"
637            logger.write_failure_to_html_report(message)
638            pytest.fail(message)

Retrieve & Validate Software Identification Information

@pytest.mark.profile
def test_software_identification_information(self) -> None:
472    @pytest.mark.profile
473    def test_software_identification_information(self) -> None:
474        """
475        | Description          | Retrieve Software Identification                                 |
476        | :------------------- | :--------------------------------------------------------------- |
477        | GitHub Issue         | turnaroundfactor/BMS-HW-Test#394                                 |
478        | Instructions         | 1. Request Software Identification                          </br>\
479                                 2. Validate Software Identification                         </br>\
480                                 3. Log Response                                                  |
481        | Estimated Duration   | 21 seconds                                                       |
482        """
483
484        soft_request = CANFrame(pgn=PGN["RQST"], data=[0xFEDA])
485        soft_data = []
486        with CANBus(BATTERY_CHANNEL) as bus:
487            if soft_frame := bus.process_call(soft_request):
488                # Complete RTS
489                if len(soft_frame.data) < 3:
490                    message = (
491                        f"Unexpected byte response from PGN "
492                        f"{PGN['Software Identification', [32]].id} (Software Identification)"
493                    )
494                    _GENERATED_PROFILE.software_identification_information = BadStates.WRONG_PACKETS.name
495                    logger.write_warning_to_html_report(message)
496                    _GENERATED_PROFILE.software_identification_specification = "invalid"
497                    return
498                expected_bytes = int(soft_frame.data[1])
499                expected_packets = 0
500                if _GENERATED_PROFILE.manufacturer_code == ManufacturerID.BRENTRONICS:
501                    expected_packets = int(soft_frame.data[2] + 1)
502                else:
503                    expected_packets = int(soft_frame.data[2])
504
505                rts_pgn_id = int(soft_frame.data[-1])
506                cts_request = CANFrame(pgn=PGN["TP.CM"], data=[17, 0, 1, 0xFF, 0])
507                cts_request.data[1] = expected_packets
508                cts_request.data[-1] = rts_pgn_id
509
510                # Send CTS
511                bus.send_message(cts_request.message())
512
513                # Read & Store data from packets
514                for i in range(expected_packets):
515                    data_frame = bus.read_frame()
516                    if data_frame.pgn.id != 0xEB00:
517                        message = f"Expected data frame {i + 1}, got PGN {data_frame.pgn.id}"
518                        _GENERATED_PROFILE.software_identification_information = BadStates.WRONG_PACKETS.name
519                        _GENERATED_PROFILE.software_identification_specification = "invalid"
520                        logger.write_warning_to_html_report(message)
521                        return
522                    soft_data.append(hex(int(data_frame.data[1])))
523
524                if len(soft_data) != expected_packets:
525                    message = f"Expected {expected_packets} packets, got {len(soft_data)}"
526                    logger.write_warning_to_html_report(message)
527                    _GENERATED_PROFILE.software_identification_specification = "invalid"
528                    _GENERATED_PROFILE.software_identification_information = BadStates.WRONG_PACKETS.name
529                    return
530
531                # Send acknowledgement frame
532                end_acknowledge_frame = CANFrame(pgn=PGN["TP.CM"], data=[19, 0, 0, 0xFF, 0])
533                end_acknowledge_frame.data[1] = expected_bytes
534                end_acknowledge_frame.data[2] = expected_packets
535                end_acknowledge_frame.data[-1] = rts_pgn_id
536
537                bus.send_message(end_acknowledge_frame.message())
538            else:
539                message = f"No response for PGN {PGN['Software Identification', [32]].id} (Software Identification)"
540                logger.write_warning_to_html_report(message)
541                _GENERATED_PROFILE.software_identification_information = BadStates.NO_RESPONSE.name
542                _GENERATED_PROFILE.software_identification_specification = "invalid"
543                return
544
545        hex_string = "0x"
546        add_to_data = False
547        finish_adding = False
548
549        # Find Software Identification Number
550        for element in soft_data:
551            n = len(element) - 1
552            while n >= 1:
553                if add_to_data:
554                    if element[n - 1 : n + 1] == "2a":
555                        finish_adding = True
556                        break
557                    if element[n - 1 : n + 1] != "0x":
558                        hex_string += element[n - 1 : n + 1]
559                        n -= 2
560                    else:
561                        n -= 2
562                else:
563                    if element[n - 3 : n + 1] == "2a31" or element[n - 3 : n + 1] == "2a01":
564                        add_to_data = True
565                        n -= 4
566                    else:
567                        n -= 4
568            if finish_adding:
569                break
570
571        if len(hex_string) <= 2:
572            logger.write_warning_to_html_report(
573                f"Software Identification: {hex_string} was not found in expected format"
574            )
575            _GENERATED_PROFILE.software_identification_information = hex_string
576            _GENERATED_PROFILE.software_identification_specification = "invalid"
577            return
578
579        software_identification = spn_types.ascii_map(int(hex_string, 16))
580
581        if re.search(r"[0-9]{2}\.[0-9]{2}\.[a-z]{2}\.[a-z]{2,}", software_identification) is None:
582            message = f"Software Identification: {software_identification} was not in expected format: MM.II.mm.aa.ee"
583            logger.write_warning_to_html_report(message)
584            _GENERATED_PROFILE.software_identification_specification = "invalid"
585        else:
586            _GENERATED_PROFILE.software_identification_specification = "valid"
587
588        _GENERATED_PROFILE.software_identification = software_identification
589
590        logger.write_result_to_html_report(f"Software Identification: {software_identification}")
Description Retrieve Software Identification
GitHub Issue turnaroundfactor/BMS-HW-Test#394
Instructions 1. Request Software Identification
2. Validate Software Identification
3. Log Response
Estimated Duration 21 seconds
def test_comparison(self) -> None:
592    def test_comparison(self) -> None:
593        """
594        | Description          | Compare Software Identification values                           |
595        | :------------------- | :--------------------------------------------------------------- |
596        | GitHub Issue         | turnaroundfactor/BMS-HW-Test#394                                 |
597        | Instructions         | 1. Confirm software versions are the same for each profile       |
598        | Pass / Fail Criteria | Pass if identity numbers match for each profile                  |
599        | Estimated Duration   | 1 second                                                         |
600        """
601        failed_test = False
602        if not hasattr(_GENERATED_PROFILE, "software_identification") or not hasattr(
603            _COMPARISON_PROFILE, "software_identification"
604        ):
605            message = "Nothing to compare. The profile test may have failed or been skipped"
606            logger.write_result_to_html_report(message)
607            pytest.skip(message)
608
609        compare = cmp(_GENERATED_PROFILE.software_identification, "==", _COMPARISON_PROFILE.software_identification)
610        if _GENERATED_PROFILE.software_identification != _COMPARISON_PROFILE.software_identification:
611            logger.write_failure_to_html_report(f"Software Identification: {compare}")
612            failed_test = True
613        else:
614            logger.write_result_to_html_report(f"Software Identification: {compare}")
615
616        if not hasattr(_GENERATED_PROFILE, "software_identification_specification") or not hasattr(
617            _COMPARISON_PROFILE, "software_identification_specification"
618        ):
619            logger.write_warning_to_html_report("Missing specification information")
620        else:
621            comparison = cmp(
622                _GENERATED_PROFILE.software_identification_specification,
623                "==",
624                _COMPARISON_PROFILE.software_identification_specification,
625            )
626            if (
627                _GENERATED_PROFILE.software_identification_specification
628                != _COMPARISON_PROFILE.software_identification_specification
629            ):
630                logger.write_failure_to_html_report(f"Specification comparison failed: {comparison}")
631                failed_test = True
632            else:
633                logger.write_result_to_html_report(f"Specification comparison passed: {comparison}")
634
635        if failed_test:
636            message = "Comparisons did not match in profiles"
637            logger.write_failure_to_html_report(message)
638            pytest.fail(message)
Description Compare Software Identification values
GitHub Issue turnaroundfactor/BMS-HW-Test#394
Instructions 1. Confirm software versions are the same for each profile
Pass / Fail Criteria Pass if identity numbers match for each profile
Estimated Duration 1 second
class TestBatteryRegulationInformation:
641class TestBatteryRegulationInformation:
642    """Retrieve and validate battery regulation information"""
643
644    @pytest.mark.profile
645    def test_profile(self) -> None:
646        """
647        | Description          | Obtain Battery Regulation Information 1 & 2                      |
648        | :------------------- | :--------------------------------------------------------------- |
649        | GitHub Issue         | turnaroundfactor/BMS-HW-Test#392                                 |
650        | Instructions         | 1. Request Battery Regulation Information 1                 </br>\
651                                 2. Validate Information                                     </br>\
652                                 3. Log Response                                                  |
653        | Estimated Duration   | 22 seconds                                                       |
654        """
655
656        battery_regulation_one_request = CANFrame(pgn=PGN["RQST"], data=[0xFF03])
657        battery_regulation_two_request = CANFrame(pgn=PGN["RQST"], data=[0xFF04])
658
659        # Obtain Battery Regulation One Information
660        with CANBus(BATTERY_CHANNEL) as bus:
661            if battery_frame := bus.process_call(battery_regulation_one_request):
662                logger.write_result_to_html_report(
663                    "<span style='font-weight: bold'>Battery Regulation Information 1 </span>"
664                )
665                if battery_frame.pgn.id != PGN["PropB_03", [32]].id:
666                    message = f"Expected {PGN['PropB_03', [32]].id} 0xFF03, but received PGN {battery_frame.pgn.id}"
667                    logger.write_warning_to_html_report(message)
668                    _GENERATED_PROFILE.battery_regulation_one = BadStates.WRONG_PGN.name
669                    _GENERATED_PROFILE.battery_regulation_one_specification = "invalid"
670                else:
671                    _GENERATED_PROFILE.battery_regulation_one = {
672                        "battery_voltage": battery_frame.data[0],
673                        "open_circuit_voltage": battery_frame.data[1],
674                        "battery_current": battery_frame.data[2],
675                        "maximum_charge_current": battery_frame.data[3],
676                    }
677
678                    for spn, elem in zip(PGN["PropB_03"].data_field, battery_frame.data):
679                        description = f"({desc})" if (desc := spn.data_type(elem)) else ""
680                        logger.write_result_to_html_report(f"{spn.name}: {elem} {description}")
681
682                        high_range = 3212.75
683                        low_range = 0.0
684                        if spn.name == "Battery Current":
685                            high_range = 1600.00
686                            low_range = -1600.00
687
688                        if not low_range <= elem <= high_range:
689                            logger.write_warning_to_html_report(
690                                f"{spn.name}: {cmp(elem, '>=', low_range)} and " f"{cmp(elem, '<=', high_range)}"
691                            )
692                            _GENERATED_PROFILE.battery_regulation_one_specification = "invalid"
693
694                    if not hasattr(_GENERATED_PROFILE, "battery_regulation_one_specification"):
695                        _GENERATED_PROFILE.battery_regulation_one_specification = "valid"
696
697            else:
698                message = f"Did not receive response for PGN {PGN['PropB_03', [32]].id} (Battery Regulation One)"
699                logger.write_warning_to_html_report(message)
700                _GENERATED_PROFILE.battery_regulation_one = BadStates.NO_RESPONSE.name
701                _GENERATED_PROFILE.battery_regulation_one_specification = "invalid"
702
703        # Obtain Battery Regulation Two Information
704        with CANBus(BATTERY_CHANNEL) as bus:
705            if battery_frame_two := bus.process_call(battery_regulation_two_request):
706                logger.write_result_to_html_report(
707                    "<span style='font-weight: bold'>Battery Regulation Information 2</span>"
708                )
709
710                if battery_frame_two.pgn.id != PGN["PropB_04", [32]].id:
711                    message = f"Expected PGN {PGN['PropB_04', [32]].id}, but received PGN {battery_frame_two.pgn.id}"
712                    logger.write_warning_to_html_report(message)
713                    _GENERATED_PROFILE.battery_regulation_two = BadStates.NO_RESPONSE.name
714                    _GENERATED_PROFILE.battery_regulation_two_specification = "invalid"
715                else:
716                    _GENERATED_PROFILE.battery_regulation_two = {
717                        "contractor_state": battery_frame_two.data[0],
718                        "charge_capability_state": battery_frame_two.data[1],
719                        "bus_voltage_request": battery_frame_two.data[3],
720                        "transportability_soc": battery_frame_two.data[4],
721                    }
722
723                    for spn, elem in zip(PGN["PropB_04"].data_field, battery_frame_two.data):
724
725                        if spn.name == "Reserved":
726                            continue
727
728                        description = f"({desc})" if (desc := spn.data_type(elem)) else ""
729                        logger.write_result_to_html_report(f"{spn.name}: {elem} {description}")
730
731                        high_range = 3
732                        if spn.name == "Bus Voltage Request":
733                            high_range = 3212.75
734
735                        if spn.name == "Transportability SOC":
736                            high_range = 100
737
738                        if not 0 <= elem <= high_range:
739                            logger.write_warning_to_html_report(
740                                f"{spn.name}: {cmp(elem, '>=', 0)} and {cmp(elem, '<=', high_range)}"
741                            )
742                            _GENERATED_PROFILE.battery_regulation_two_specification = "invalid"
743
744                    if not hasattr(_GENERATED_PROFILE, "battery_regulation_two_specification"):
745                        _GENERATED_PROFILE.battery_regulation_two_specification = "valid"
746
747            else:
748                message = f"Did not receive response for PGN {PGN['PropB_04', [32]].id} (Battery Regulation Two)"
749                logger.write_warning_to_html_report(message)
750                _GENERATED_PROFILE.battery_regulation_two = BadStates.NO_RESPONSE.name
751                _GENERATED_PROFILE.battery_regulation_two_specification = "invalid"
752
753    # def test_comparison(self) -> None:
754    #     # TODO: FIX
755    #     """
756    #     | Description          | Compare Battery Regulation values                                |
757    #     | :------------------- | :--------------------------------------------------------------- |
758    #     | GitHub Issue         | turnaroundfactor/BMS-HW-Test#394                                 |
759    #     | Instructions         | 1. Confirm Battery Regulation One profiles match            </br>\
760    #                              2. Confirm Battery Regulation Two profiles match                 |
761    #     | Pass / Fail Criteria | Pass if Battery Regulation values match for each profile         |
762    #     | Estimated Duration   | 1 second                                                         |
763    #     """
764    #
765    #     def loop_profiles(generated, comparison) -> list[str]:
766    #         fail_list = []
767    #         for key, value in generated.items():
768    #             key_text = key.replace("_", " ").title()
769    #             message = cmp(value, "==", comparison[key])
770    #             if comparison[key] != value:
771    #                 logger.write_warning_to_html_report(f"{key_text}: {message}")
772    #                 fail_list.append(key_text)
773    #             else:
774    #                 logger.write_result_to_html_report(f"{key_text}: {message}")
775    #
776    #         return fail_list
777    #
778    #     test_failed = False
779    #     reg_one_list: list[str] = []
780    #     reg_two_list: list[str] = []
781    #     if not hasattr(_GENERATED_PROFILE, "battery_regulation_one") or not hasattr(
782    #         _COMPARISON_PROFILE, "battery_regulation_one"
783    #     ):
784    #         message = "Nothing to compare. The profile test may have failed or been skipped"
785    #         logger.write_result_to_html_report(message)
786    #         pytest.skip(message)
787    #
788    #     if not hasattr(_GENERATED_PROFILE, "battery_regulation_two") or not hasattr(
789    #         _COMPARISON_PROFILE, "battery_regulation_two"
790    #     ):
791    #         message = "Nothing to compare. The profile test may have failed or been skipped"
792    #         logger.write_result_to_html_report(message)
793    #         pytest.skip(message)
794    #
795    #     logger.write_result_to_html_report(
796    #     "<span style='font-weight: bold'>Battery Regulation One Comparisons</span>"
797    #     )
798    #     if isinstance(_GENERATED_PROFILE.battery_regulation_one, str) or isinstance(
799    #         _COMPARISON_PROFILE.battery_regulation_one, str
800    #     ):
801    #         if not isinstance(
802    #             _GENERATED_PROFILE.battery_regulation_one, type(_COMPARISON_PROFILE.battery_regulation_one)
803    #         ):
804    #             message = "Unable to compare profiles, Battery Regulation One types did not match"
805    #             logger.write_warning_to_html_report(message)
806    #             pytest.fail(message)
807    #
808    #         comparison_text = cmp(
809    #             _GENERATED_PROFILE.battery_regulation_one, "==", _COMPARISON_PROFILE.battery_regulation_one
810    #         )
811    #
812    #         if _GENERATED_PROFILE.battery_regulation_one != _COMPARISON_PROFILE.battery_regulation_one:
813    #             message = f"Battery Regulation One did not match: {comparison_text}"
814    #             logger.write_warning_to_html_report(message)
815    #             test_failed = True
816    #     else:
817    #         reg_one_list = loop_profiles(
818    #             _GENERATED_PROFILE.battery_regulation_one, _COMPARISON_PROFILE.battery_regulation_one
819    #         )
820    #
821    #     logger.write_result_to_html_report(
822    #       "<span style='font-weight: bold'>Battery Regulation Two Comparisons</span>"
823    #     )
824    #     if isinstance(_GENERATED_PROFILE.battery_regulation_two, str) or isinstance(
825    #         _COMPARISON_PROFILE.battery_regulation_two, str
826    #     ):
827    #         if not isinstance(
828    #             _GENERATED_PROFILE.battery_regulation_two, type(_COMPARISON_PROFILE.battery_regulation_two)
829    #         ):
830    #             message = "Unable to compare profiles, Battery Regulation One types did not match"
831    #             logger.write_warning_to_html_report(message)
832    #             pytest.fail(message)
833    #
834    #         comparison_text = cmp(
835    #             _GENERATED_PROFILE.battery_regulation_two, "==", _COMPARISON_PROFILE.battery_regulation_two
836    #         )
837    #
838    #         if _GENERATED_PROFILE.battery_regulation_two != _COMPARISON_PROFILE.battery_regulation_two:
839    #             message = f"Battery Regulation Two did not match: {comparison_text}"
840    #             logger.write_warning_to_html_report(message)
841    #             test_failed = True
842    #     else:
843    #         reg_two_list = loop_profiles(
844    #             _GENERATED_PROFILE.battery_regulation_two, _COMPARISON_PROFILE.battery_regulation_two
845    #         )
846    #
847    #     if test_failed:
848    #         message = "Battery Regulation values did not match"
849    #         logger.write_failure_to_html_report(message)
850    #         pytest.fail(message)
851    #
852    #     fail_categories = reg_one_list + reg_two_list
853    #
854    #     if len(fail_categories) > 0:
855    #         categories = "data values" if len(fail_categories) > 1 else "data value"
856    #         message = f"{len(fail_categories)} {categories} failed profile comparisons: {', '.join(fail_categories)}"
857    #         logger.write_failure_to_html_report(message)
858    #         pytest.fail(message)
859    #     else:
860    #         logger.write_result_to_html_report("Battery Regulation 1 and 2 profiles match")

Retrieve and validate battery regulation information

@pytest.mark.profile
def test_profile(self) -> None:
644    @pytest.mark.profile
645    def test_profile(self) -> None:
646        """
647        | Description          | Obtain Battery Regulation Information 1 & 2                      |
648        | :------------------- | :--------------------------------------------------------------- |
649        | GitHub Issue         | turnaroundfactor/BMS-HW-Test#392                                 |
650        | Instructions         | 1. Request Battery Regulation Information 1                 </br>\
651                                 2. Validate Information                                     </br>\
652                                 3. Log Response                                                  |
653        | Estimated Duration   | 22 seconds                                                       |
654        """
655
656        battery_regulation_one_request = CANFrame(pgn=PGN["RQST"], data=[0xFF03])
657        battery_regulation_two_request = CANFrame(pgn=PGN["RQST"], data=[0xFF04])
658
659        # Obtain Battery Regulation One Information
660        with CANBus(BATTERY_CHANNEL) as bus:
661            if battery_frame := bus.process_call(battery_regulation_one_request):
662                logger.write_result_to_html_report(
663                    "<span style='font-weight: bold'>Battery Regulation Information 1 </span>"
664                )
665                if battery_frame.pgn.id != PGN["PropB_03", [32]].id:
666                    message = f"Expected {PGN['PropB_03', [32]].id} 0xFF03, but received PGN {battery_frame.pgn.id}"
667                    logger.write_warning_to_html_report(message)
668                    _GENERATED_PROFILE.battery_regulation_one = BadStates.WRONG_PGN.name
669                    _GENERATED_PROFILE.battery_regulation_one_specification = "invalid"
670                else:
671                    _GENERATED_PROFILE.battery_regulation_one = {
672                        "battery_voltage": battery_frame.data[0],
673                        "open_circuit_voltage": battery_frame.data[1],
674                        "battery_current": battery_frame.data[2],
675                        "maximum_charge_current": battery_frame.data[3],
676                    }
677
678                    for spn, elem in zip(PGN["PropB_03"].data_field, battery_frame.data):
679                        description = f"({desc})" if (desc := spn.data_type(elem)) else ""
680                        logger.write_result_to_html_report(f"{spn.name}: {elem} {description}")
681
682                        high_range = 3212.75
683                        low_range = 0.0
684                        if spn.name == "Battery Current":
685                            high_range = 1600.00
686                            low_range = -1600.00
687
688                        if not low_range <= elem <= high_range:
689                            logger.write_warning_to_html_report(
690                                f"{spn.name}: {cmp(elem, '>=', low_range)} and " f"{cmp(elem, '<=', high_range)}"
691                            )
692                            _GENERATED_PROFILE.battery_regulation_one_specification = "invalid"
693
694                    if not hasattr(_GENERATED_PROFILE, "battery_regulation_one_specification"):
695                        _GENERATED_PROFILE.battery_regulation_one_specification = "valid"
696
697            else:
698                message = f"Did not receive response for PGN {PGN['PropB_03', [32]].id} (Battery Regulation One)"
699                logger.write_warning_to_html_report(message)
700                _GENERATED_PROFILE.battery_regulation_one = BadStates.NO_RESPONSE.name
701                _GENERATED_PROFILE.battery_regulation_one_specification = "invalid"
702
703        # Obtain Battery Regulation Two Information
704        with CANBus(BATTERY_CHANNEL) as bus:
705            if battery_frame_two := bus.process_call(battery_regulation_two_request):
706                logger.write_result_to_html_report(
707                    "<span style='font-weight: bold'>Battery Regulation Information 2</span>"
708                )
709
710                if battery_frame_two.pgn.id != PGN["PropB_04", [32]].id:
711                    message = f"Expected PGN {PGN['PropB_04', [32]].id}, but received PGN {battery_frame_two.pgn.id}"
712                    logger.write_warning_to_html_report(message)
713                    _GENERATED_PROFILE.battery_regulation_two = BadStates.NO_RESPONSE.name
714                    _GENERATED_PROFILE.battery_regulation_two_specification = "invalid"
715                else:
716                    _GENERATED_PROFILE.battery_regulation_two = {
717                        "contractor_state": battery_frame_two.data[0],
718                        "charge_capability_state": battery_frame_two.data[1],
719                        "bus_voltage_request": battery_frame_two.data[3],
720                        "transportability_soc": battery_frame_two.data[4],
721                    }
722
723                    for spn, elem in zip(PGN["PropB_04"].data_field, battery_frame_two.data):
724
725                        if spn.name == "Reserved":
726                            continue
727
728                        description = f"({desc})" if (desc := spn.data_type(elem)) else ""
729                        logger.write_result_to_html_report(f"{spn.name}: {elem} {description}")
730
731                        high_range = 3
732                        if spn.name == "Bus Voltage Request":
733                            high_range = 3212.75
734
735                        if spn.name == "Transportability SOC":
736                            high_range = 100
737
738                        if not 0 <= elem <= high_range:
739                            logger.write_warning_to_html_report(
740                                f"{spn.name}: {cmp(elem, '>=', 0)} and {cmp(elem, '<=', high_range)}"
741                            )
742                            _GENERATED_PROFILE.battery_regulation_two_specification = "invalid"
743
744                    if not hasattr(_GENERATED_PROFILE, "battery_regulation_two_specification"):
745                        _GENERATED_PROFILE.battery_regulation_two_specification = "valid"
746
747            else:
748                message = f"Did not receive response for PGN {PGN['PropB_04', [32]].id} (Battery Regulation Two)"
749                logger.write_warning_to_html_report(message)
750                _GENERATED_PROFILE.battery_regulation_two = BadStates.NO_RESPONSE.name
751                _GENERATED_PROFILE.battery_regulation_two_specification = "invalid"
Description Obtain Battery Regulation Information 1 & 2
GitHub Issue turnaroundfactor/BMS-HW-Test#392
Instructions 1. Request Battery Regulation Information 1
2. Validate Information
3. Log Response
Estimated Duration 22 seconds
class TestPowerSupply:
 863class TestPowerSupply:
 864    """Retrieve and validate battery regulation information"""
 865
 866    @pytest.mark.profile
 867    def test_charge_power_supply(self) -> None:
 868        """
 869        | Description          | Check Battery Information after Charge                           |
 870        | :------------------- | :--------------------------------------------------------------- |
 871        | GitHub Issue         | turnaroundfactor/BMS-HW-Test#597                                 |
 872        | Instructions         | 1. Request Battery State of Charge                          </br>\
 873                                 2. Charge battery at 0.1A for 1 minute                      </br>\
 874                                 3. Check & save Battery Regulation 1 & 2 information        </br>\
 875                                 4. Log Response                                                  |
 876        | Estimated Duration   | 2 minutes                                                        |
 877        """
 878
 879        battery_calculation_one = CANFrame(pgn=PGN["RQST"], data=[PGN["PropB_02", [32]].id])
 880
 881        with CANBus(BATTERY_CHANNEL) as bus:
 882            if battery_calculation_response := bus.process_call(battery_calculation_one):
 883                if battery_calculation_response.pgn.id != PGN["PropB_02", [32]].id:
 884                    message = (
 885                        f"Expected PGN {PGN['PropB_02', [32]].id} ({PGN['PropB_02', [32]].name}) "
 886                        f" but received PGN {battery_calculation_response.pgn.id}"
 887                    )
 888                    logger.write_warning_to_html_report(message)
 889                    _GENERATED_PROFILE.test_power_supply = BadStates.WRONG_PGN.name
 890                    pytest.skip(message)
 891
 892                for spn, elem in zip(PGN["PropB_02"].data_field, battery_calculation_response.data):
 893                    low_range = 0
 894                    high_range = 95
 895                    if spn.name == "Battery State of Charge":
 896                        comparison = f"{cmp(elem, '>=', low_range, '%')} " f"and {cmp(elem, '<=', high_range, '%' )}"
 897
 898                        if not low_range <= elem <= high_range:
 899                            message = (
 900                                f"Skipping Test: {spn.name} is {elem}%, "
 901                                f"which is not within expected range: {comparison}"
 902                            )
 903                            logger.write_warning_to_html_report(message)
 904                            _GENERATED_PROFILE.test_power_supply = BadStates.SKIPPED_TEST.name
 905                            pytest.skip(message)
 906                        else:
 907                            message = f"{spn.name} is {elem}%"
 908                            logger.write_result_to_html_report(message)
 909                        break
 910            else:
 911                message = f"Did not receive response for PGN {PGN['PropB_02', [32]].id} ({PGN['PropB_02', [32]].name})"
 912                logger.write_warning_to_html_report(message)
 913                _GENERATED_PROFILE.test_power_supply = BadStates.NO_RESPONSE.name
 914                pytest.skip(message)
 915
 916            if not hasattr(_GENERATED_PROFILE, "battery_regulation_one") and not hasattr(
 917                _GENERATED_PROFILE, "battery_regulation_two"
 918            ):
 919                default_b_one = self.get_battery_regulation_one()
 920                default_b_two = self.get_battery_regulation_two()
 921            else:
 922                default_b_one = _GENERATED_PROFILE.battery_regulation_one
 923                default_b_two = _GENERATED_PROFILE.battery_regulation_two
 924
 925            if isinstance(default_b_one, str) or isinstance(default_b_two, str):
 926                _GENERATED_PROFILE.test_power_supply = BadStates.INVALID_RESPONSE.name
 927                logger.write_warning_to_html_report("Missing valid Battery Regulation One and Two info")
 928                pytest.skip("Missing valid Battery Regulation One and Two info")
 929
 930            logger.write_result_to_html_report("Charging battery")
 931            try:
 932                cyber_charger = charger.Charger()
 933            except RuntimeError:
 934                message = "Could not communicate with power supply"
 935                logger.write_warning_to_html_report(message)
 936                pytest.fail(message)
 937
 938            try:
 939                with cyber_charger(0.1, float(default_b_two.get("bus_voltage_request", 0.0))):
 940                    logger.write_info_to_report("Battery Regulation One Values from Charge")
 941                    updated_b_one = self.get_battery_regulation_one()
 942                    logger.write_info_to_report("Battery Regulation Two Values from Charge")
 943                    updated_b_two = self.get_battery_regulation_two()
 944
 945                    if isinstance(updated_b_one, str):
 946                        if updated_b_one == "No Current":
 947                            _GENERATED_PROFILE.test_charge_power_supply = "Current not detected"
 948                            raise RuntimeError
 949
 950                        _GENERATED_PROFILE.test_power_supply = BadStates.INVALID_RESPONSE.name
 951                        logger.write_warning_to_html_report("Missing valid Battery Regulation One and Two info")
 952                        pytest.skip("Missing valid Battery Regulation One and Two info")
 953
 954                    _GENERATED_PROFILE.charge_b_one = updated_b_one
 955                    _GENERATED_PROFILE.charge_b_two = updated_b_two
 956
 957            except RuntimeError:
 958                if cyber_charger.resource:
 959                    if hasattr(cyber_charger.resource, "close"):
 960                        cyber_charger.resource.close()
 961                message = "Could not communicate with power supply or current not detected"
 962                logger.write_warning_to_html_report(message)
 963
 964                if not hasattr(_GENERATED_PROFILE, "test_charge_power_supply"):
 965                    _GENERATED_PROFILE.test_charge_power_supply = "Could not communicate"
 966                pytest.fail(message)
 967
 968            except AttributeError:
 969                message = "Could not communicate with power supply"
 970                logger.write_warning_to_html_report(message)
 971                _GENERATED_PROFILE.test_discharge_power_supply = "Could not communicate"
 972                pytest.fail(message)
 973
 974    def test_charge_comparison(self):
 975        """
 976        | Description          | Compare Power Supply Charge values                               |
 977        | :------------------- | :--------------------------------------------------------------- |
 978        | GitHub Issue         | turnaroundfactor/BMS-HW-Test#393                                 |
 979        | Instructions         | 1. Check if charge Battery Current is within 10% of initial value|
 980        | Pass / Fail Criteria | Pass if charged value is within 10%                              |
 981        | Estimated Duration   | 1 second                                                         |
 982        """
 983
 984        if hasattr(_GENERATED_PROFILE, "test_power_supply") or not hasattr(_GENERATED_PROFILE, "charge_b_one"):
 985            message = "Nothing to compare. Profile test may have failed or been skipped"
 986            logger.write_result_to_html_report(message)
 987            pytest.skip(message)
 988
 989        if hasattr(_COMPARISON_PROFILE, "test_power_supply") or not hasattr(_COMPARISON_PROFILE, "charge_b_one"):
 990            message = "Nothing to compare. Profile test may have failed or been skipped"
 991            logger.write_result_to_html_report(message)
 992            pytest.skip(message)
 993
 994        if isinstance(_GENERATED_PROFILE.charge_b_one, str) or isinstance(_COMPARISON_PROFILE.charge_b_one, str):
 995            if not isinstance(_GENERATED_PROFILE.charge_b_one, type(_COMPARISON_PROFILE.charge_b_one)):
 996                message = "Unable to compare profiles, types of Battery Regulation Information 1 did not match"
 997                logger.write_failure_to_html_report(message)
 998                pytest.fail(message)
 999
1000            comparison = cmp(_GENERATED_PROFILE.charge_b_one, "==", _COMPARISON_PROFILE.charge_b_one)
1001
1002            if _GENERATED_PROFILE.charge_b_one != _COMPARISON_PROFILE.charge_b_one:
1003                message = f"Battery Regulation Information 1 matched profiles: {comparison}"
1004                logger.write_failure_to_html_report(message)
1005                pytest.fail(message)
1006
1007            message = f"Battery Regulation Information 1 matched: {comparison}"
1008            logger.write_result_to_html_report(message)
1009            return
1010
1011        original_current = _GENERATED_PROFILE.charge_b_one.get("battery_current")
1012        comparison_current = _COMPARISON_PROFILE.charge_b_one.get("battery_current")
1013
1014        diff = abs(original_current - comparison_current)
1015        average = abs(original_current + comparison_current) / 2
1016        percentage = round((diff / average), 4)
1017        percent_closeness = 0.1
1018
1019        comparison = cmp(percentage * 100, "<=", percent_closeness * 100, "%")
1020
1021        if not math.isclose(original_current, comparison_current, rel_tol=0.1):
1022            logger.write_warning_to_html_report(
1023                f"Battery Regulation Information 1 current percent difference: {comparison}"
1024            )
1025            logger.write_failure_to_html_report("Current was not within expected range during charge")
1026            pytest.fail("Current was not within expected range during charge")
1027
1028        logger.write_result_to_html_report(f"Battery Regulation Information 1 current percent difference: {comparison}")
1029        logger.write_result_to_html_report("Current was within expected range during charge")
1030
1031    @pytest.mark.profile
1032    def test_discharge_power_supply(self) -> None:
1033        """
1034        | Description          | Check Battery Information after Discharge                        |
1035        | :------------------- | :--------------------------------------------------------------- |
1036        | GitHub Issue         | turnaroundfactor/BMS-HW-Test#597                                 |
1037        | Instructions         | 1. Request Battery State of Charge                          </br>\
1038                                 2. Discharge battery at -0.1A for 1 minute                  </br>\
1039                                 3. Check & save Battery Regulation 1 & 2 information        </br>\
1040                                 4. Log Response                                                  |
1041        | Estimated Duration   | 2 minutes                                                        |
1042        """
1043
1044        battery_calculation_one = CANFrame(pgn=PGN["RQST"], data=[PGN["PropB_02", [32]].id])
1045
1046        with CANBus(BATTERY_CHANNEL) as bus:
1047            if battery_calculation_response := bus.process_call(battery_calculation_one):
1048                if battery_calculation_response.pgn.id != PGN["PropB_02", [32]].id:
1049                    message = (
1050                        f"Expected PGN {PGN['PropB_02', [32]].id} ({PGN['PropB_02', [32]].name}) "
1051                        f" but received PGN {battery_calculation_response.pgn.id}"
1052                    )
1053                    logger.write_warning_to_html_report(message)
1054                    _GENERATED_PROFILE.test_discharge_power_supply = BadStates.WRONG_PGN.name
1055                    return
1056
1057                for spn, elem in zip(PGN["PropB_02"].data_field, battery_calculation_response.data):
1058                    if spn.name == "Battery State of Charge":
1059                        low_range = 5
1060                        high_range = 100
1061                        comparison = f"{cmp(elem, '>=', low_range, '%')} and {cmp(elem, '<=', high_range, '%' )}"
1062
1063                        if not low_range <= elem <= high_range:
1064                            message = (
1065                                f"Skipping Test: {spn.name} is {elem}%, "
1066                                f"which is not within expected range: {comparison}"
1067                            )
1068                            logger.write_warning_to_html_report(message)
1069                            _GENERATED_PROFILE.test_discharge_power_supply = BadStates.SKIPPED_TEST.name
1070
1071                            pytest.skip(message)
1072                        else:
1073                            message = f"{spn.name} is {elem}%"
1074                            logger.write_result_to_html_report(message)
1075                        break
1076            else:
1077                message = f"Did not receive response for PGN {PGN['PropB_02', [32]].id} ({PGN['PropB_02', [32]].name})"
1078                logger.write_warning_to_html_report(message)
1079                _GENERATED_PROFILE.test_discharge_power_supply = BadStates.NO_RESPONSE.name
1080                pytest.skip(message)
1081
1082            if not hasattr(_GENERATED_PROFILE, "battery_regulation_one") and not hasattr(
1083                _GENERATED_PROFILE, "battery_regulation_two"
1084            ):
1085                default_b_one = self.get_battery_regulation_one()
1086                default_b_two = self.get_battery_regulation_two()
1087            else:
1088                default_b_one = _GENERATED_PROFILE.battery_regulation_one
1089                default_b_two = _GENERATED_PROFILE.battery_regulation_two
1090
1091            if isinstance(default_b_one, str) or isinstance(default_b_two, str):
1092                _GENERATED_PROFILE.test_discharge_power_supply = BadStates.INVALID_RESPONSE.name
1093
1094                logger.write_warning_to_html_report("Missing valid Battery Regulation One and Two info")
1095                pytest.skip("Missing valid Battery Regulation One and Two info")
1096
1097            logger.write_result_to_html_report("Discharging battery")
1098            try:
1099                cyber_charger = charger.Charger()
1100            except RuntimeError:
1101                message = "Could not communicate with power supply"
1102                logger.write_warning_to_html_report(message)
1103                pytest.fail(message)
1104            try:
1105                with cyber_charger(-0.1):
1106                    logger.write_info_to_report("Battery Regulation One Values from Discharge")
1107                    updated_b_one = self.get_battery_regulation_one()
1108                    logger.write_info_to_report("Battery Regulation Two Values from Discharge")
1109                    updated_b_two = self.get_battery_regulation_two()
1110
1111                    if isinstance(updated_b_one, str):
1112                        if updated_b_one == "No Current":
1113                            _GENERATED_PROFILE.test_discharge_power_supply = "Current not detected"
1114                            raise RuntimeError
1115
1116                        _GENERATED_PROFILE.test_power_supply = BadStates.INVALID_RESPONSE.name
1117                        logger.write_warning_to_html_report("Missing valid Battery Regulation One and Two info")
1118                        pytest.skip("Missing valid Battery Regulation One and Two info")
1119
1120                    _GENERATED_PROFILE.discharge_b_one = updated_b_one
1121                    _GENERATED_PROFILE.discharge_b_two = updated_b_two
1122
1123            except RuntimeError:
1124                if cyber_charger.resource:
1125                    if hasattr(cyber_charger.resource, "close"):
1126                        cyber_charger.resource.close()
1127                message = "Could not communicate with power supply or current not detected"
1128                logger.write_warning_to_html_report(message)
1129                if not hasattr(_GENERATED_PROFILE, "test_charge_power_supply"):
1130                    _GENERATED_PROFILE.test_discharge_power_supply = "Could not communicate"
1131                pytest.fail(message)
1132
1133            except AttributeError:
1134                message = "Could not communicate with power supply"
1135                logger.write_warning_to_html_report(message)
1136                _GENERATED_PROFILE.test_discharge_power_supply = "Could not communicate"
1137                pytest.fail(message)
1138
1139    def test_discharge_comparison(self):
1140        """
1141        | Description          | Compare Power Supply Discharge values                            |
1142        | :------------------- | :--------------------------------------------------------------- |
1143        | GitHub Issue         | turnaroundfactor/BMS-HW-Test#393                                 |
1144        | Instructions         | 1. Check if discharge Battery Current is within 10% of initial value|
1145        | Pass / Fail Criteria | Pass if discharged value is within 10%                           |
1146        | Estimated Duration   | 1 second                                                         |
1147        """
1148
1149        if hasattr(_GENERATED_PROFILE, "test_discharge_power_supply") or not hasattr(
1150            _GENERATED_PROFILE, "discharge_b_one"
1151        ):
1152            message = "Nothing to compare. The profile test may have failed or been skipped"
1153            logger.write_result_to_html_report(message)
1154            pytest.skip(message)
1155
1156        if hasattr(_COMPARISON_PROFILE, "test_discharge_power_supply") or not hasattr(
1157            _COMPARISON_PROFILE, "discharge_b_one"
1158        ):
1159            message = "Nothing to compare. The profile test may have failed or been skipped"
1160            logger.write_result_to_html_report(message)
1161            pytest.skip(message)
1162
1163        if isinstance(_GENERATED_PROFILE.discharge_b_one, str) or isinstance(_COMPARISON_PROFILE.discharge_b_one, str):
1164            if not isinstance(_GENERATED_PROFILE.discharge_b_one, type(_COMPARISON_PROFILE.discharge_b_one)):
1165                message = "Unable to compare profiles, types of Battery Regulation Information 1 did not match"
1166                logger.write_failure_to_html_report(message)
1167                pytest.fail(message)
1168
1169            comparison = cmp(_GENERATED_PROFILE.discharge_b_one, "==", _COMPARISON_PROFILE.discharge_b_one)
1170
1171            if _GENERATED_PROFILE.discharge_b_one != _COMPARISON_PROFILE.discharge_b_one:
1172                message = f"Battery Regulation Information 1 matched profiles: {comparison}"
1173                logger.write_failure_to_html_report(message)
1174                pytest.fail(message)
1175
1176            message = f"Battery Regulation Information 1 matched: {comparison}"
1177            logger.write_result_to_html_report(message)
1178            return
1179
1180        original_current = _GENERATED_PROFILE.discharge_b_one.get("battery_current")
1181        comparison_current = _COMPARISON_PROFILE.discharge_b_one.get("battery_current")
1182
1183        diff = abs(original_current - comparison_current)
1184        average = abs(original_current + comparison_current) / 2
1185        percentage = round((diff / average), 4)
1186        percent_closeness = 0.1
1187
1188        comparison = cmp(percentage * 100, "<=", percent_closeness * 100, "%")
1189
1190        if not math.isclose(original_current, comparison_current, rel_tol=0.1):
1191            logger.write_warning_to_html_report(
1192                f"Battery Regulation Information 1 current percent difference: {comparison}"
1193            )
1194            logger.write_failure_to_html_report("Current was not within expected range of 10% during discharge")
1195            pytest.fail("Current was not within expected range of 10% during charge")
1196
1197        logger.write_result_to_html_report(f"Battery regulation Information 1 current percent difference: {comparison}")
1198        logger.write_result_to_html_report("Current was within expected range of 10% during discharge")
1199
1200    @staticmethod
1201    def get_battery_regulation_one() -> str | dict[str, int | Any]:
1202        """Obtain the Battery Regulation One information"""
1203        battery_regulation_one_request = CANFrame(pgn=PGN["RQST"], data=[0xFF03])
1204        logger.write_result_to_html_report("<span style='font-weight: bold'>Battery Regulation Information 1</span>")
1205
1206        with CANBus(BATTERY_CHANNEL) as bus:
1207            battery_regulation_one: str | dict[str, int | Any]
1208            if battery_frame := bus.process_call(battery_regulation_one_request):
1209                if battery_frame.pgn.id != PGN["PropB_03", [32]].id:
1210                    message = f"Expected {PGN['PropB_03', [32]].id} 0xFF03, but received PGN {battery_frame.pgn.id}"
1211                    logger.write_warning_to_html_report(message)
1212                    battery_regulation_one = BadStates.WRONG_PGN.name
1213                else:
1214                    battery_regulation_one = {
1215                        "battery_voltage": battery_frame.data[0],
1216                        "open_circuit_voltage": battery_frame.data[1],
1217                        "battery_current": battery_frame.data[2],
1218                        "maximum_charge_current": battery_frame.data[3],
1219                    }
1220
1221                    for spn, elem in zip(PGN["PropB_03"].data_field, battery_frame.data):
1222
1223                        if spn.name == "Reserved":
1224                            continue
1225
1226                        description = f"({desc})" if (desc := spn.data_type(elem)) else ""
1227                        logger.write_result_to_html_report(f"{spn.name}: {elem} {description}")
1228
1229                        if spn.name == "Battery Current":
1230                            if -0.1 <= elem <= 0.1:
1231                                message = (
1232                                    "Current was not detected. The power supply leads may not be fully "
1233                                    "connected, or the fuse has blown"
1234                                )
1235                                logger.write_warning_to_html_report(message)
1236                                return "No Current"
1237
1238            else:
1239                message = f"Did not receive response for PGN {PGN['PropB_03', [32]].id} (Battery Regulation One)"
1240                logger.write_warning_to_html_report(message)
1241                battery_regulation_one = BadStates.NO_RESPONSE.name
1242
1243            return battery_regulation_one
1244
1245    @staticmethod
1246    def get_battery_regulation_two() -> str | dict[str, int | Any]:
1247        """Obtain the Battery Regulation Two information"""
1248        battery_regulation_two_request = CANFrame(pgn=PGN["RQST"], data=[0xFF04])
1249        logger.write_result_to_html_report("<span style='font-weight: bold'>Battery Regulation Information 2</span>")
1250
1251        with CANBus(BATTERY_CHANNEL) as bus:
1252            battery_regulation_two: str | dict[str, int | Any]
1253            if battery_frame_two := bus.process_call(battery_regulation_two_request):
1254                if battery_frame_two.pgn.id != PGN["PropB_04", [32]].id:
1255                    message = f"Expected PGN {PGN['PropB_04', [32]].id}, but received PGN {battery_frame_two.pgn.id}"
1256                    logger.write_warning_to_html_report(message)
1257                    battery_regulation_two = BadStates.NO_RESPONSE.name
1258                else:
1259                    battery_regulation_two = {
1260                        "contractor_state": battery_frame_two.data[0],
1261                        "charge_capability_state": battery_frame_two.data[1],
1262                        "bus_voltage_request": battery_frame_two.data[3],
1263                        "transportability_soc": battery_frame_two.data[4],
1264                    }
1265
1266                    for spn, elem in zip(PGN["PropB_04"].data_field, battery_frame_two.data):
1267
1268                        if spn.name == "Reserved":
1269                            continue
1270
1271                        description = f"({desc})" if (desc := spn.data_type(elem)) else ""
1272                        logger.write_result_to_html_report(f"{spn.name}: {elem} {description}")
1273
1274            else:
1275                message = f"Did not receive response for PGN {PGN['PropB_04', [32]].id} (Battery Regulation Two)"
1276                logger.write_warning_to_html_report(message)
1277                battery_regulation_two = BadStates.NO_RESPONSE.name
1278
1279            return battery_regulation_two

Retrieve and validate battery regulation information

@pytest.mark.profile
def test_charge_power_supply(self) -> None:
866    @pytest.mark.profile
867    def test_charge_power_supply(self) -> None:
868        """
869        | Description          | Check Battery Information after Charge                           |
870        | :------------------- | :--------------------------------------------------------------- |
871        | GitHub Issue         | turnaroundfactor/BMS-HW-Test#597                                 |
872        | Instructions         | 1. Request Battery State of Charge                          </br>\
873                                 2. Charge battery at 0.1A for 1 minute                      </br>\
874                                 3. Check & save Battery Regulation 1 & 2 information        </br>\
875                                 4. Log Response                                                  |
876        | Estimated Duration   | 2 minutes                                                        |
877        """
878
879        battery_calculation_one = CANFrame(pgn=PGN["RQST"], data=[PGN["PropB_02", [32]].id])
880
881        with CANBus(BATTERY_CHANNEL) as bus:
882            if battery_calculation_response := bus.process_call(battery_calculation_one):
883                if battery_calculation_response.pgn.id != PGN["PropB_02", [32]].id:
884                    message = (
885                        f"Expected PGN {PGN['PropB_02', [32]].id} ({PGN['PropB_02', [32]].name}) "
886                        f" but received PGN {battery_calculation_response.pgn.id}"
887                    )
888                    logger.write_warning_to_html_report(message)
889                    _GENERATED_PROFILE.test_power_supply = BadStates.WRONG_PGN.name
890                    pytest.skip(message)
891
892                for spn, elem in zip(PGN["PropB_02"].data_field, battery_calculation_response.data):
893                    low_range = 0
894                    high_range = 95
895                    if spn.name == "Battery State of Charge":
896                        comparison = f"{cmp(elem, '>=', low_range, '%')} " f"and {cmp(elem, '<=', high_range, '%' )}"
897
898                        if not low_range <= elem <= high_range:
899                            message = (
900                                f"Skipping Test: {spn.name} is {elem}%, "
901                                f"which is not within expected range: {comparison}"
902                            )
903                            logger.write_warning_to_html_report(message)
904                            _GENERATED_PROFILE.test_power_supply = BadStates.SKIPPED_TEST.name
905                            pytest.skip(message)
906                        else:
907                            message = f"{spn.name} is {elem}%"
908                            logger.write_result_to_html_report(message)
909                        break
910            else:
911                message = f"Did not receive response for PGN {PGN['PropB_02', [32]].id} ({PGN['PropB_02', [32]].name})"
912                logger.write_warning_to_html_report(message)
913                _GENERATED_PROFILE.test_power_supply = BadStates.NO_RESPONSE.name
914                pytest.skip(message)
915
916            if not hasattr(_GENERATED_PROFILE, "battery_regulation_one") and not hasattr(
917                _GENERATED_PROFILE, "battery_regulation_two"
918            ):
919                default_b_one = self.get_battery_regulation_one()
920                default_b_two = self.get_battery_regulation_two()
921            else:
922                default_b_one = _GENERATED_PROFILE.battery_regulation_one
923                default_b_two = _GENERATED_PROFILE.battery_regulation_two
924
925            if isinstance(default_b_one, str) or isinstance(default_b_two, str):
926                _GENERATED_PROFILE.test_power_supply = BadStates.INVALID_RESPONSE.name
927                logger.write_warning_to_html_report("Missing valid Battery Regulation One and Two info")
928                pytest.skip("Missing valid Battery Regulation One and Two info")
929
930            logger.write_result_to_html_report("Charging battery")
931            try:
932                cyber_charger = charger.Charger()
933            except RuntimeError:
934                message = "Could not communicate with power supply"
935                logger.write_warning_to_html_report(message)
936                pytest.fail(message)
937
938            try:
939                with cyber_charger(0.1, float(default_b_two.get("bus_voltage_request", 0.0))):
940                    logger.write_info_to_report("Battery Regulation One Values from Charge")
941                    updated_b_one = self.get_battery_regulation_one()
942                    logger.write_info_to_report("Battery Regulation Two Values from Charge")
943                    updated_b_two = self.get_battery_regulation_two()
944
945                    if isinstance(updated_b_one, str):
946                        if updated_b_one == "No Current":
947                            _GENERATED_PROFILE.test_charge_power_supply = "Current not detected"
948                            raise RuntimeError
949
950                        _GENERATED_PROFILE.test_power_supply = BadStates.INVALID_RESPONSE.name
951                        logger.write_warning_to_html_report("Missing valid Battery Regulation One and Two info")
952                        pytest.skip("Missing valid Battery Regulation One and Two info")
953
954                    _GENERATED_PROFILE.charge_b_one = updated_b_one
955                    _GENERATED_PROFILE.charge_b_two = updated_b_two
956
957            except RuntimeError:
958                if cyber_charger.resource:
959                    if hasattr(cyber_charger.resource, "close"):
960                        cyber_charger.resource.close()
961                message = "Could not communicate with power supply or current not detected"
962                logger.write_warning_to_html_report(message)
963
964                if not hasattr(_GENERATED_PROFILE, "test_charge_power_supply"):
965                    _GENERATED_PROFILE.test_charge_power_supply = "Could not communicate"
966                pytest.fail(message)
967
968            except AttributeError:
969                message = "Could not communicate with power supply"
970                logger.write_warning_to_html_report(message)
971                _GENERATED_PROFILE.test_discharge_power_supply = "Could not communicate"
972                pytest.fail(message)
Description Check Battery Information after Charge
GitHub Issue turnaroundfactor/BMS-HW-Test#597
Instructions 1. Request Battery State of Charge
2. Charge battery at 0.1A for 1 minute
3. Check & save Battery Regulation 1 & 2 information
4. Log Response
Estimated Duration 2 minutes
def test_charge_comparison(self):
 974    def test_charge_comparison(self):
 975        """
 976        | Description          | Compare Power Supply Charge values                               |
 977        | :------------------- | :--------------------------------------------------------------- |
 978        | GitHub Issue         | turnaroundfactor/BMS-HW-Test#393                                 |
 979        | Instructions         | 1. Check if charge Battery Current is within 10% of initial value|
 980        | Pass / Fail Criteria | Pass if charged value is within 10%                              |
 981        | Estimated Duration   | 1 second                                                         |
 982        """
 983
 984        if hasattr(_GENERATED_PROFILE, "test_power_supply") or not hasattr(_GENERATED_PROFILE, "charge_b_one"):
 985            message = "Nothing to compare. Profile test may have failed or been skipped"
 986            logger.write_result_to_html_report(message)
 987            pytest.skip(message)
 988
 989        if hasattr(_COMPARISON_PROFILE, "test_power_supply") or not hasattr(_COMPARISON_PROFILE, "charge_b_one"):
 990            message = "Nothing to compare. Profile test may have failed or been skipped"
 991            logger.write_result_to_html_report(message)
 992            pytest.skip(message)
 993
 994        if isinstance(_GENERATED_PROFILE.charge_b_one, str) or isinstance(_COMPARISON_PROFILE.charge_b_one, str):
 995            if not isinstance(_GENERATED_PROFILE.charge_b_one, type(_COMPARISON_PROFILE.charge_b_one)):
 996                message = "Unable to compare profiles, types of Battery Regulation Information 1 did not match"
 997                logger.write_failure_to_html_report(message)
 998                pytest.fail(message)
 999
1000            comparison = cmp(_GENERATED_PROFILE.charge_b_one, "==", _COMPARISON_PROFILE.charge_b_one)
1001
1002            if _GENERATED_PROFILE.charge_b_one != _COMPARISON_PROFILE.charge_b_one:
1003                message = f"Battery Regulation Information 1 matched profiles: {comparison}"
1004                logger.write_failure_to_html_report(message)
1005                pytest.fail(message)
1006
1007            message = f"Battery Regulation Information 1 matched: {comparison}"
1008            logger.write_result_to_html_report(message)
1009            return
1010
1011        original_current = _GENERATED_PROFILE.charge_b_one.get("battery_current")
1012        comparison_current = _COMPARISON_PROFILE.charge_b_one.get("battery_current")
1013
1014        diff = abs(original_current - comparison_current)
1015        average = abs(original_current + comparison_current) / 2
1016        percentage = round((diff / average), 4)
1017        percent_closeness = 0.1
1018
1019        comparison = cmp(percentage * 100, "<=", percent_closeness * 100, "%")
1020
1021        if not math.isclose(original_current, comparison_current, rel_tol=0.1):
1022            logger.write_warning_to_html_report(
1023                f"Battery Regulation Information 1 current percent difference: {comparison}"
1024            )
1025            logger.write_failure_to_html_report("Current was not within expected range during charge")
1026            pytest.fail("Current was not within expected range during charge")
1027
1028        logger.write_result_to_html_report(f"Battery Regulation Information 1 current percent difference: {comparison}")
1029        logger.write_result_to_html_report("Current was within expected range during charge")
Description Compare Power Supply Charge values
GitHub Issue turnaroundfactor/BMS-HW-Test#393
Instructions 1. Check if charge Battery Current is within 10% of initial value
Pass / Fail Criteria Pass if charged value is within 10%
Estimated Duration 1 second
@pytest.mark.profile
def test_discharge_power_supply(self) -> None:
1031    @pytest.mark.profile
1032    def test_discharge_power_supply(self) -> None:
1033        """
1034        | Description          | Check Battery Information after Discharge                        |
1035        | :------------------- | :--------------------------------------------------------------- |
1036        | GitHub Issue         | turnaroundfactor/BMS-HW-Test#597                                 |
1037        | Instructions         | 1. Request Battery State of Charge                          </br>\
1038                                 2. Discharge battery at -0.1A for 1 minute                  </br>\
1039                                 3. Check & save Battery Regulation 1 & 2 information        </br>\
1040                                 4. Log Response                                                  |
1041        | Estimated Duration   | 2 minutes                                                        |
1042        """
1043
1044        battery_calculation_one = CANFrame(pgn=PGN["RQST"], data=[PGN["PropB_02", [32]].id])
1045
1046        with CANBus(BATTERY_CHANNEL) as bus:
1047            if battery_calculation_response := bus.process_call(battery_calculation_one):
1048                if battery_calculation_response.pgn.id != PGN["PropB_02", [32]].id:
1049                    message = (
1050                        f"Expected PGN {PGN['PropB_02', [32]].id} ({PGN['PropB_02', [32]].name}) "
1051                        f" but received PGN {battery_calculation_response.pgn.id}"
1052                    )
1053                    logger.write_warning_to_html_report(message)
1054                    _GENERATED_PROFILE.test_discharge_power_supply = BadStates.WRONG_PGN.name
1055                    return
1056
1057                for spn, elem in zip(PGN["PropB_02"].data_field, battery_calculation_response.data):
1058                    if spn.name == "Battery State of Charge":
1059                        low_range = 5
1060                        high_range = 100
1061                        comparison = f"{cmp(elem, '>=', low_range, '%')} and {cmp(elem, '<=', high_range, '%' )}"
1062
1063                        if not low_range <= elem <= high_range:
1064                            message = (
1065                                f"Skipping Test: {spn.name} is {elem}%, "
1066                                f"which is not within expected range: {comparison}"
1067                            )
1068                            logger.write_warning_to_html_report(message)
1069                            _GENERATED_PROFILE.test_discharge_power_supply = BadStates.SKIPPED_TEST.name
1070
1071                            pytest.skip(message)
1072                        else:
1073                            message = f"{spn.name} is {elem}%"
1074                            logger.write_result_to_html_report(message)
1075                        break
1076            else:
1077                message = f"Did not receive response for PGN {PGN['PropB_02', [32]].id} ({PGN['PropB_02', [32]].name})"
1078                logger.write_warning_to_html_report(message)
1079                _GENERATED_PROFILE.test_discharge_power_supply = BadStates.NO_RESPONSE.name
1080                pytest.skip(message)
1081
1082            if not hasattr(_GENERATED_PROFILE, "battery_regulation_one") and not hasattr(
1083                _GENERATED_PROFILE, "battery_regulation_two"
1084            ):
1085                default_b_one = self.get_battery_regulation_one()
1086                default_b_two = self.get_battery_regulation_two()
1087            else:
1088                default_b_one = _GENERATED_PROFILE.battery_regulation_one
1089                default_b_two = _GENERATED_PROFILE.battery_regulation_two
1090
1091            if isinstance(default_b_one, str) or isinstance(default_b_two, str):
1092                _GENERATED_PROFILE.test_discharge_power_supply = BadStates.INVALID_RESPONSE.name
1093
1094                logger.write_warning_to_html_report("Missing valid Battery Regulation One and Two info")
1095                pytest.skip("Missing valid Battery Regulation One and Two info")
1096
1097            logger.write_result_to_html_report("Discharging battery")
1098            try:
1099                cyber_charger = charger.Charger()
1100            except RuntimeError:
1101                message = "Could not communicate with power supply"
1102                logger.write_warning_to_html_report(message)
1103                pytest.fail(message)
1104            try:
1105                with cyber_charger(-0.1):
1106                    logger.write_info_to_report("Battery Regulation One Values from Discharge")
1107                    updated_b_one = self.get_battery_regulation_one()
1108                    logger.write_info_to_report("Battery Regulation Two Values from Discharge")
1109                    updated_b_two = self.get_battery_regulation_two()
1110
1111                    if isinstance(updated_b_one, str):
1112                        if updated_b_one == "No Current":
1113                            _GENERATED_PROFILE.test_discharge_power_supply = "Current not detected"
1114                            raise RuntimeError
1115
1116                        _GENERATED_PROFILE.test_power_supply = BadStates.INVALID_RESPONSE.name
1117                        logger.write_warning_to_html_report("Missing valid Battery Regulation One and Two info")
1118                        pytest.skip("Missing valid Battery Regulation One and Two info")
1119
1120                    _GENERATED_PROFILE.discharge_b_one = updated_b_one
1121                    _GENERATED_PROFILE.discharge_b_two = updated_b_two
1122
1123            except RuntimeError:
1124                if cyber_charger.resource:
1125                    if hasattr(cyber_charger.resource, "close"):
1126                        cyber_charger.resource.close()
1127                message = "Could not communicate with power supply or current not detected"
1128                logger.write_warning_to_html_report(message)
1129                if not hasattr(_GENERATED_PROFILE, "test_charge_power_supply"):
1130                    _GENERATED_PROFILE.test_discharge_power_supply = "Could not communicate"
1131                pytest.fail(message)
1132
1133            except AttributeError:
1134                message = "Could not communicate with power supply"
1135                logger.write_warning_to_html_report(message)
1136                _GENERATED_PROFILE.test_discharge_power_supply = "Could not communicate"
1137                pytest.fail(message)
Description Check Battery Information after Discharge
GitHub Issue turnaroundfactor/BMS-HW-Test#597
Instructions 1. Request Battery State of Charge
2. Discharge battery at -0.1A for 1 minute
3. Check & save Battery Regulation 1 & 2 information
4. Log Response
Estimated Duration 2 minutes
def test_discharge_comparison(self):
1139    def test_discharge_comparison(self):
1140        """
1141        | Description          | Compare Power Supply Discharge values                            |
1142        | :------------------- | :--------------------------------------------------------------- |
1143        | GitHub Issue         | turnaroundfactor/BMS-HW-Test#393                                 |
1144        | Instructions         | 1. Check if discharge Battery Current is within 10% of initial value|
1145        | Pass / Fail Criteria | Pass if discharged value is within 10%                           |
1146        | Estimated Duration   | 1 second                                                         |
1147        """
1148
1149        if hasattr(_GENERATED_PROFILE, "test_discharge_power_supply") or not hasattr(
1150            _GENERATED_PROFILE, "discharge_b_one"
1151        ):
1152            message = "Nothing to compare. The profile test may have failed or been skipped"
1153            logger.write_result_to_html_report(message)
1154            pytest.skip(message)
1155
1156        if hasattr(_COMPARISON_PROFILE, "test_discharge_power_supply") or not hasattr(
1157            _COMPARISON_PROFILE, "discharge_b_one"
1158        ):
1159            message = "Nothing to compare. The profile test may have failed or been skipped"
1160            logger.write_result_to_html_report(message)
1161            pytest.skip(message)
1162
1163        if isinstance(_GENERATED_PROFILE.discharge_b_one, str) or isinstance(_COMPARISON_PROFILE.discharge_b_one, str):
1164            if not isinstance(_GENERATED_PROFILE.discharge_b_one, type(_COMPARISON_PROFILE.discharge_b_one)):
1165                message = "Unable to compare profiles, types of Battery Regulation Information 1 did not match"
1166                logger.write_failure_to_html_report(message)
1167                pytest.fail(message)
1168
1169            comparison = cmp(_GENERATED_PROFILE.discharge_b_one, "==", _COMPARISON_PROFILE.discharge_b_one)
1170
1171            if _GENERATED_PROFILE.discharge_b_one != _COMPARISON_PROFILE.discharge_b_one:
1172                message = f"Battery Regulation Information 1 matched profiles: {comparison}"
1173                logger.write_failure_to_html_report(message)
1174                pytest.fail(message)
1175
1176            message = f"Battery Regulation Information 1 matched: {comparison}"
1177            logger.write_result_to_html_report(message)
1178            return
1179
1180        original_current = _GENERATED_PROFILE.discharge_b_one.get("battery_current")
1181        comparison_current = _COMPARISON_PROFILE.discharge_b_one.get("battery_current")
1182
1183        diff = abs(original_current - comparison_current)
1184        average = abs(original_current + comparison_current) / 2
1185        percentage = round((diff / average), 4)
1186        percent_closeness = 0.1
1187
1188        comparison = cmp(percentage * 100, "<=", percent_closeness * 100, "%")
1189
1190        if not math.isclose(original_current, comparison_current, rel_tol=0.1):
1191            logger.write_warning_to_html_report(
1192                f"Battery Regulation Information 1 current percent difference: {comparison}"
1193            )
1194            logger.write_failure_to_html_report("Current was not within expected range of 10% during discharge")
1195            pytest.fail("Current was not within expected range of 10% during charge")
1196
1197        logger.write_result_to_html_report(f"Battery regulation Information 1 current percent difference: {comparison}")
1198        logger.write_result_to_html_report("Current was within expected range of 10% during discharge")
Description Compare Power Supply Discharge values
GitHub Issue turnaroundfactor/BMS-HW-Test#393
Instructions 1. Check if discharge Battery Current is within 10% of initial value
Pass / Fail Criteria Pass if discharged value is within 10%
Estimated Duration 1 second
@staticmethod
def get_battery_regulation_one() -> str | dict[str, int | typing.Any]:
1200    @staticmethod
1201    def get_battery_regulation_one() -> str | dict[str, int | Any]:
1202        """Obtain the Battery Regulation One information"""
1203        battery_regulation_one_request = CANFrame(pgn=PGN["RQST"], data=[0xFF03])
1204        logger.write_result_to_html_report("<span style='font-weight: bold'>Battery Regulation Information 1</span>")
1205
1206        with CANBus(BATTERY_CHANNEL) as bus:
1207            battery_regulation_one: str | dict[str, int | Any]
1208            if battery_frame := bus.process_call(battery_regulation_one_request):
1209                if battery_frame.pgn.id != PGN["PropB_03", [32]].id:
1210                    message = f"Expected {PGN['PropB_03', [32]].id} 0xFF03, but received PGN {battery_frame.pgn.id}"
1211                    logger.write_warning_to_html_report(message)
1212                    battery_regulation_one = BadStates.WRONG_PGN.name
1213                else:
1214                    battery_regulation_one = {
1215                        "battery_voltage": battery_frame.data[0],
1216                        "open_circuit_voltage": battery_frame.data[1],
1217                        "battery_current": battery_frame.data[2],
1218                        "maximum_charge_current": battery_frame.data[3],
1219                    }
1220
1221                    for spn, elem in zip(PGN["PropB_03"].data_field, battery_frame.data):
1222
1223                        if spn.name == "Reserved":
1224                            continue
1225
1226                        description = f"({desc})" if (desc := spn.data_type(elem)) else ""
1227                        logger.write_result_to_html_report(f"{spn.name}: {elem} {description}")
1228
1229                        if spn.name == "Battery Current":
1230                            if -0.1 <= elem <= 0.1:
1231                                message = (
1232                                    "Current was not detected. The power supply leads may not be fully "
1233                                    "connected, or the fuse has blown"
1234                                )
1235                                logger.write_warning_to_html_report(message)
1236                                return "No Current"
1237
1238            else:
1239                message = f"Did not receive response for PGN {PGN['PropB_03', [32]].id} (Battery Regulation One)"
1240                logger.write_warning_to_html_report(message)
1241                battery_regulation_one = BadStates.NO_RESPONSE.name
1242
1243            return battery_regulation_one

Obtain the Battery Regulation One information

@staticmethod
def get_battery_regulation_two() -> str | dict[str, int | typing.Any]:
1245    @staticmethod
1246    def get_battery_regulation_two() -> str | dict[str, int | Any]:
1247        """Obtain the Battery Regulation Two information"""
1248        battery_regulation_two_request = CANFrame(pgn=PGN["RQST"], data=[0xFF04])
1249        logger.write_result_to_html_report("<span style='font-weight: bold'>Battery Regulation Information 2</span>")
1250
1251        with CANBus(BATTERY_CHANNEL) as bus:
1252            battery_regulation_two: str | dict[str, int | Any]
1253            if battery_frame_two := bus.process_call(battery_regulation_two_request):
1254                if battery_frame_two.pgn.id != PGN["PropB_04", [32]].id:
1255                    message = f"Expected PGN {PGN['PropB_04', [32]].id}, but received PGN {battery_frame_two.pgn.id}"
1256                    logger.write_warning_to_html_report(message)
1257                    battery_regulation_two = BadStates.NO_RESPONSE.name
1258                else:
1259                    battery_regulation_two = {
1260                        "contractor_state": battery_frame_two.data[0],
1261                        "charge_capability_state": battery_frame_two.data[1],
1262                        "bus_voltage_request": battery_frame_two.data[3],
1263                        "transportability_soc": battery_frame_two.data[4],
1264                    }
1265
1266                    for spn, elem in zip(PGN["PropB_04"].data_field, battery_frame_two.data):
1267
1268                        if spn.name == "Reserved":
1269                            continue
1270
1271                        description = f"({desc})" if (desc := spn.data_type(elem)) else ""
1272                        logger.write_result_to_html_report(f"{spn.name}: {elem} {description}")
1273
1274            else:
1275                message = f"Did not receive response for PGN {PGN['PropB_04', [32]].id} (Battery Regulation Two)"
1276                logger.write_warning_to_html_report(message)
1277                battery_regulation_two = BadStates.NO_RESPONSE.name
1278
1279            return battery_regulation_two

Obtain the Battery Regulation Two information

class TestConfigurationStateMessage:
1282class TestConfigurationStateMessage:
1283    """Confirms Configuration State Message matches profile after factory reset"""
1284
1285    @pytest.mark.profile
1286    def test_read(self) -> None:
1287        """
1288        | Description          | Checks if Configuration State Message matches profile            |
1289        | :------------------- | :--------------------------------------------------------------- |
1290        | GitHub Issue         | turnaroundfactor/BMS-HW-Test#393                                 |
1291        | Instructions         | 1. Request Configuration State                              </br>\
1292                                 2. Check if Configuration State matches default values      </br>\
1293                                 3. Log results                                                   |
1294        | Estimated Duration   | 21 seconds                                                       |
1295        """
1296
1297        configuration_state_request = CANFrame(pgn=PGN["Request"], data=[PGN["Configuration State Message 1"].id])
1298        with CANBus(BATTERY_CHANNEL) as bus:
1299            if configuration_frame := bus.process_call(configuration_state_request):
1300                if configuration_frame.pgn.id != PGN["Configuration State Message 1", [32]].id:
1301                    message = (
1302                        f"Received PGN {configuration_frame.pgn.id}, not PGN "
1303                        f"{PGN['Configuration State Message 1', [32]].id} "
1304                    )
1305                    logger.write_failure_to_html_report(message)
1306                    _GENERATED_PROFILE.configuration_state_message = BadStates.WRONG_PGN.name
1307                    _GENERATED_PROFILE.configuration_state_specification = "invalid"
1308                    return
1309
1310                data = configuration_frame.data
1311                pgn_data_field = PGN["Configuration State Message 1"].data_field
1312
1313                if len(data) != len(pgn_data_field):
1314                    message = (
1315                        f"Expected {len(pgn_data_field)} data fields, "
1316                        f"got {len(data)}. Data is missing from response"
1317                    )
1318                    logger.write_warning_to_html_report(message)
1319                    _GENERATED_PROFILE.configuration_state_message = BadStates.WRONG_PACKETS.name
1320                    _GENERATED_PROFILE.configuration_state_specification = "invalid"
1321                    return
1322
1323                logger.write_result_to_html_report("<span style='font-weight: bold'>Configuration State Message</span>")
1324
1325                for spn, elem in zip(pgn_data_field, data):
1326                    if spn.name == "Reserved":
1327                        continue
1328
1329                    if not 0 <= elem <= 3:
1330                        message = (
1331                            f"{spn.name}: {cmp(elem, '>=', 0, f'({spn.data_type(elem)})')} and "
1332                            f"{cmp(elem, '<=', 3, f'({spn.data_type(elem)})')}"
1333                        )
1334                        logger.write_warning_to_html_report(message)
1335                        _GENERATED_PROFILE.configuration_state_specification = "invalid"
1336                        continue
1337
1338                    description = f"({desc})" if (desc := spn.data_type(elem)) else ""
1339                    logger.write_result_to_html_report(f"{spn.name}: {elem} {description}")
1340
1341                    if spn.name in ("Battery Battle-override State", "Standby State", "Configure VPMS Function State"):
1342                        message = (
1343                            f"{spn.name}: {cmp(elem, '==', 0, f'({spn.data_type(elem)})', f'({spn.data_type(0)})')}"
1344                        )
1345                        if elem != 0:
1346                            _GENERATED_PROFILE.configuration_state_specification = "invalid"
1347                            logger.write_warning_to_html_report(message)
1348
1349                    if spn.name in ("Automated Heater Function State", "Contactor(s) Control State"):
1350                        message = (
1351                            f"{spn.name}: {cmp(elem, '==', 1, f'({spn.data_type(elem)})', f'({spn.data_type(1)})')}"
1352                        )
1353                        if elem != 1:
1354                            _GENERATED_PROFILE.configuration_state_specification = "invalid"
1355                            logger.write_warning_to_html_report(message)
1356
1357                configuration_state_message = {
1358                    "dormant_1_state": data[0],
1359                    "dormant_2_state": data[1],
1360                    "master_power_switch": data[2],
1361                    "configuration_pin_1_state": data[3],
1362                    "configuration_pin_2_state": data[4],
1363                    "configuration_pin_3_state": data[5],
1364                    "configuration_pin_4_state": data[6],
1365                    "configuration_pin_5_state": data[7],
1366                    "configuration_pin_6_state": data[8],
1367                    "virtual_master_power_switch_state": data[9],
1368                    "battery_battle_override_state": data[11],
1369                    "maintenance_state": data[12],
1370                    "automated_heater_function_state": data[13],
1371                    "battery_heater_state": data[14],
1372                    "contractor_control_state": data[15],
1373                    "standby_state": data[16],
1374                    "baud_rate_overwrite_state": data[18],
1375                    "position_identity_overwrite_state": data[19],
1376                    "configure_vpms_function_state": data[20],
1377                    "pulse_power_control_state": data[21],
1378                }
1379
1380                _GENERATED_PROFILE.configuration_state_message = configuration_state_message
1381
1382                if not hasattr(_GENERATED_PROFILE, "configuration_state_specification"):
1383                    _GENERATED_PROFILE.configuration_state_specification = "valid"
1384
1385            else:
1386                message = (
1387                    f"Did not receive a response for PGN {PGN['Configuration State Message 1', [32]].id} "
1388                    f"(Configuration State Message 1)"
1389                )
1390                _GENERATED_PROFILE.configuration_state_message = BadStates.NO_RESPONSE.name
1391                _GENERATED_PROFILE.configuration_state_specification = "invalid"
1392                logger.write_warning_to_html_report(message)
1393
1394    def test_comparison(self) -> None:
1395        """
1396        | Description          | Compare Configuration State Message 1 values                     |
1397        | :------------------- | :--------------------------------------------------------------- |
1398        | GitHub Issue         | turnaroundfactor/BMS-HW-Test#393                                 |
1399        | Instructions         | 1. Check if Configuration State Message 1 values match           |
1400        | Pass / Fail Criteria | Pass if Configuration State Message 1 values match               |
1401        | Estimated Duration   | 1 second                                                         |
1402        """
1403        failed_test = False
1404        if not hasattr(_GENERATED_PROFILE, "configuration_state_message") or not hasattr(
1405            _COMPARISON_PROFILE, "configuration_state_message"
1406        ):
1407            message = "Nothing to compare. The profile test may have failed or been skipped"
1408            logger.write_result_to_html_report(message)
1409            pytest.skip(message)
1410
1411        failed_categories = []
1412
1413        logger.write_result_to_html_report(
1414            "<span style='font-weight: bold'>Configuration State Message Comparisons</span>"
1415        )
1416
1417        if isinstance(_GENERATED_PROFILE.configuration_state_message, str) or isinstance(
1418            _COMPARISON_PROFILE.configuration_state_message, str
1419        ):
1420            if not isinstance(
1421                _GENERATED_PROFILE.configuration_state_message, type(_COMPARISON_PROFILE.configuration_state_message)
1422            ):
1423                message = "Unable to compare profiles, Configuration State Message types did not match"
1424                logger.write_failure_to_html_report(message)
1425                pytest.fail(message)
1426
1427            comparison = cmp(
1428                _GENERATED_PROFILE.configuration_state_message, "==", _COMPARISON_PROFILE.configuration_state_message
1429            )
1430
1431            if _GENERATED_PROFILE.configuration_state_message != _COMPARISON_PROFILE.configuration_state_message:
1432                message = f"Configuration State Message did not match: {comparison}"
1433                logger.write_failure_to_html_report(message)
1434                pytest.fail(message)
1435
1436            message = f"Configuration State Message matched: {comparison}"
1437            logger.write_result_to_html_report(message)
1438        else:
1439
1440            for key, value in _GENERATED_PROFILE.configuration_state_message.items():
1441                key_text = key.replace("_", " ").title()
1442                comparison = cmp(value, "==", _COMPARISON_PROFILE.configuration_state_message[key])
1443                message = f"{key_text}: {comparison}"
1444
1445                if value != _COMPARISON_PROFILE.configuration_state_message[key]:
1446                    logger.write_warning_to_html_report(message)
1447                    failed_categories.append(key_text)
1448                else:
1449                    logger.write_result_to_html_report(message)
1450
1451            fail_length = len(failed_categories)
1452
1453            if fail_length > 0:
1454                categories = "data values" if fail_length > 1 else "data value"
1455                message = f"{fail_length} {categories} failed profile comparisons: {', '.join(failed_categories)}"
1456                logger.write_failure_to_html_report(message)
1457                failed_test = True
1458
1459        if not hasattr(_GENERATED_PROFILE, "configuration_state_specification") or not hasattr(
1460            _COMPARISON_PROFILE, "configuration_state_specification"
1461        ):
1462            logger.write_warning_to_html_report("Missing specification information")
1463        else:
1464            comparison = cmp(
1465                _GENERATED_PROFILE.configuration_state_specification,
1466                "==",
1467                _COMPARISON_PROFILE.configuration_state_specification,
1468            )
1469            if (
1470                _GENERATED_PROFILE.configuration_state_specification
1471                != _COMPARISON_PROFILE.configuration_state_specification
1472            ):
1473                logger.write_failure_to_html_report(f"Specification comparison failed: {comparison}")
1474                failed_test = True
1475            else:
1476                logger.write_result_to_html_report(f"Specification comparison passed: {comparison}")
1477
1478        if failed_test:
1479            message = "Comparisons did not match in profiles"
1480            logger.write_failure_to_html_report(message)
1481            pytest.fail(message)
1482
1483        logger.write_result_to_html_report("Configuration State Message 1 values successfully matched")

Confirms Configuration State Message matches profile after factory reset

@pytest.mark.profile
def test_read(self) -> None:
1285    @pytest.mark.profile
1286    def test_read(self) -> None:
1287        """
1288        | Description          | Checks if Configuration State Message matches profile            |
1289        | :------------------- | :--------------------------------------------------------------- |
1290        | GitHub Issue         | turnaroundfactor/BMS-HW-Test#393                                 |
1291        | Instructions         | 1. Request Configuration State                              </br>\
1292                                 2. Check if Configuration State matches default values      </br>\
1293                                 3. Log results                                                   |
1294        | Estimated Duration   | 21 seconds                                                       |
1295        """
1296
1297        configuration_state_request = CANFrame(pgn=PGN["Request"], data=[PGN["Configuration State Message 1"].id])
1298        with CANBus(BATTERY_CHANNEL) as bus:
1299            if configuration_frame := bus.process_call(configuration_state_request):
1300                if configuration_frame.pgn.id != PGN["Configuration State Message 1", [32]].id:
1301                    message = (
1302                        f"Received PGN {configuration_frame.pgn.id}, not PGN "
1303                        f"{PGN['Configuration State Message 1', [32]].id} "
1304                    )
1305                    logger.write_failure_to_html_report(message)
1306                    _GENERATED_PROFILE.configuration_state_message = BadStates.WRONG_PGN.name
1307                    _GENERATED_PROFILE.configuration_state_specification = "invalid"
1308                    return
1309
1310                data = configuration_frame.data
1311                pgn_data_field = PGN["Configuration State Message 1"].data_field
1312
1313                if len(data) != len(pgn_data_field):
1314                    message = (
1315                        f"Expected {len(pgn_data_field)} data fields, "
1316                        f"got {len(data)}. Data is missing from response"
1317                    )
1318                    logger.write_warning_to_html_report(message)
1319                    _GENERATED_PROFILE.configuration_state_message = BadStates.WRONG_PACKETS.name
1320                    _GENERATED_PROFILE.configuration_state_specification = "invalid"
1321                    return
1322
1323                logger.write_result_to_html_report("<span style='font-weight: bold'>Configuration State Message</span>")
1324
1325                for spn, elem in zip(pgn_data_field, data):
1326                    if spn.name == "Reserved":
1327                        continue
1328
1329                    if not 0 <= elem <= 3:
1330                        message = (
1331                            f"{spn.name}: {cmp(elem, '>=', 0, f'({spn.data_type(elem)})')} and "
1332                            f"{cmp(elem, '<=', 3, f'({spn.data_type(elem)})')}"
1333                        )
1334                        logger.write_warning_to_html_report(message)
1335                        _GENERATED_PROFILE.configuration_state_specification = "invalid"
1336                        continue
1337
1338                    description = f"({desc})" if (desc := spn.data_type(elem)) else ""
1339                    logger.write_result_to_html_report(f"{spn.name}: {elem} {description}")
1340
1341                    if spn.name in ("Battery Battle-override State", "Standby State", "Configure VPMS Function State"):
1342                        message = (
1343                            f"{spn.name}: {cmp(elem, '==', 0, f'({spn.data_type(elem)})', f'({spn.data_type(0)})')}"
1344                        )
1345                        if elem != 0:
1346                            _GENERATED_PROFILE.configuration_state_specification = "invalid"
1347                            logger.write_warning_to_html_report(message)
1348
1349                    if spn.name in ("Automated Heater Function State", "Contactor(s) Control State"):
1350                        message = (
1351                            f"{spn.name}: {cmp(elem, '==', 1, f'({spn.data_type(elem)})', f'({spn.data_type(1)})')}"
1352                        )
1353                        if elem != 1:
1354                            _GENERATED_PROFILE.configuration_state_specification = "invalid"
1355                            logger.write_warning_to_html_report(message)
1356
1357                configuration_state_message = {
1358                    "dormant_1_state": data[0],
1359                    "dormant_2_state": data[1],
1360                    "master_power_switch": data[2],
1361                    "configuration_pin_1_state": data[3],
1362                    "configuration_pin_2_state": data[4],
1363                    "configuration_pin_3_state": data[5],
1364                    "configuration_pin_4_state": data[6],
1365                    "configuration_pin_5_state": data[7],
1366                    "configuration_pin_6_state": data[8],
1367                    "virtual_master_power_switch_state": data[9],
1368                    "battery_battle_override_state": data[11],
1369                    "maintenance_state": data[12],
1370                    "automated_heater_function_state": data[13],
1371                    "battery_heater_state": data[14],
1372                    "contractor_control_state": data[15],
1373                    "standby_state": data[16],
1374                    "baud_rate_overwrite_state": data[18],
1375                    "position_identity_overwrite_state": data[19],
1376                    "configure_vpms_function_state": data[20],
1377                    "pulse_power_control_state": data[21],
1378                }
1379
1380                _GENERATED_PROFILE.configuration_state_message = configuration_state_message
1381
1382                if not hasattr(_GENERATED_PROFILE, "configuration_state_specification"):
1383                    _GENERATED_PROFILE.configuration_state_specification = "valid"
1384
1385            else:
1386                message = (
1387                    f"Did not receive a response for PGN {PGN['Configuration State Message 1', [32]].id} "
1388                    f"(Configuration State Message 1)"
1389                )
1390                _GENERATED_PROFILE.configuration_state_message = BadStates.NO_RESPONSE.name
1391                _GENERATED_PROFILE.configuration_state_specification = "invalid"
1392                logger.write_warning_to_html_report(message)
Description Checks if Configuration State Message matches profile
GitHub Issue turnaroundfactor/BMS-HW-Test#393
Instructions 1. Request Configuration State
2. Check if Configuration State matches default values
3. Log results
Estimated Duration 21 seconds
def test_comparison(self) -> None:
1394    def test_comparison(self) -> None:
1395        """
1396        | Description          | Compare Configuration State Message 1 values                     |
1397        | :------------------- | :--------------------------------------------------------------- |
1398        | GitHub Issue         | turnaroundfactor/BMS-HW-Test#393                                 |
1399        | Instructions         | 1. Check if Configuration State Message 1 values match           |
1400        | Pass / Fail Criteria | Pass if Configuration State Message 1 values match               |
1401        | Estimated Duration   | 1 second                                                         |
1402        """
1403        failed_test = False
1404        if not hasattr(_GENERATED_PROFILE, "configuration_state_message") or not hasattr(
1405            _COMPARISON_PROFILE, "configuration_state_message"
1406        ):
1407            message = "Nothing to compare. The profile test may have failed or been skipped"
1408            logger.write_result_to_html_report(message)
1409            pytest.skip(message)
1410
1411        failed_categories = []
1412
1413        logger.write_result_to_html_report(
1414            "<span style='font-weight: bold'>Configuration State Message Comparisons</span>"
1415        )
1416
1417        if isinstance(_GENERATED_PROFILE.configuration_state_message, str) or isinstance(
1418            _COMPARISON_PROFILE.configuration_state_message, str
1419        ):
1420            if not isinstance(
1421                _GENERATED_PROFILE.configuration_state_message, type(_COMPARISON_PROFILE.configuration_state_message)
1422            ):
1423                message = "Unable to compare profiles, Configuration State Message types did not match"
1424                logger.write_failure_to_html_report(message)
1425                pytest.fail(message)
1426
1427            comparison = cmp(
1428                _GENERATED_PROFILE.configuration_state_message, "==", _COMPARISON_PROFILE.configuration_state_message
1429            )
1430
1431            if _GENERATED_PROFILE.configuration_state_message != _COMPARISON_PROFILE.configuration_state_message:
1432                message = f"Configuration State Message did not match: {comparison}"
1433                logger.write_failure_to_html_report(message)
1434                pytest.fail(message)
1435
1436            message = f"Configuration State Message matched: {comparison}"
1437            logger.write_result_to_html_report(message)
1438        else:
1439
1440            for key, value in _GENERATED_PROFILE.configuration_state_message.items():
1441                key_text = key.replace("_", " ").title()
1442                comparison = cmp(value, "==", _COMPARISON_PROFILE.configuration_state_message[key])
1443                message = f"{key_text}: {comparison}"
1444
1445                if value != _COMPARISON_PROFILE.configuration_state_message[key]:
1446                    logger.write_warning_to_html_report(message)
1447                    failed_categories.append(key_text)
1448                else:
1449                    logger.write_result_to_html_report(message)
1450
1451            fail_length = len(failed_categories)
1452
1453            if fail_length > 0:
1454                categories = "data values" if fail_length > 1 else "data value"
1455                message = f"{fail_length} {categories} failed profile comparisons: {', '.join(failed_categories)}"
1456                logger.write_failure_to_html_report(message)
1457                failed_test = True
1458
1459        if not hasattr(_GENERATED_PROFILE, "configuration_state_specification") or not hasattr(
1460            _COMPARISON_PROFILE, "configuration_state_specification"
1461        ):
1462            logger.write_warning_to_html_report("Missing specification information")
1463        else:
1464            comparison = cmp(
1465                _GENERATED_PROFILE.configuration_state_specification,
1466                "==",
1467                _COMPARISON_PROFILE.configuration_state_specification,
1468            )
1469            if (
1470                _GENERATED_PROFILE.configuration_state_specification
1471                != _COMPARISON_PROFILE.configuration_state_specification
1472            ):
1473                logger.write_failure_to_html_report(f"Specification comparison failed: {comparison}")
1474                failed_test = True
1475            else:
1476                logger.write_result_to_html_report(f"Specification comparison passed: {comparison}")
1477
1478        if failed_test:
1479            message = "Comparisons did not match in profiles"
1480            logger.write_failure_to_html_report(message)
1481            pytest.fail(message)
1482
1483        logger.write_result_to_html_report("Configuration State Message 1 values successfully matched")
Description Compare Configuration State Message 1 values
GitHub Issue turnaroundfactor/BMS-HW-Test#393
Instructions 1. Check if Configuration State Message 1 values match
Pass / Fail Criteria Pass if Configuration State Message 1 values match
Estimated Duration 1 second
def na_maintenance_mode(bus: hitl_tester.modules.cyber_6t.canbus.CANBus) -> bool:
1486def na_maintenance_mode(bus: CANBus) -> bool:
1487    """This function will place battery in Maintenance Mode with Reset Value of "3"."""
1488
1489    maintenance_mode = CANFrame(  # Memory access only works in maintenance mode
1490        destination_address=_GENERATED_PROFILE.address,
1491        pgn=PGN["PropA"],
1492        data=[0, 0, 1, 1, 1, 3, 1, 3, 0, -1],
1493    )
1494
1495    bus.send_message(maintenance_mode.message())  # Enter maintenance mode
1496    if response := bus.read_input():
1497        ack_frame = CANFrame.decode(response.arbitration_id, response.data)
1498        if ack_frame.pgn.id != 0xE800 and ack_frame.data[0] != 0:
1499            logger.write_warning_to_html_report("Unable to enter maintenance mode")
1500            return False
1501        return True
1502
1503    message = "Received no maintenance mode response"
1504    logger.write_warning_to_html_report(message)
1505    return False

This function will place battery in Maintenance Mode with Reset Value of "3".

class TestNameManagementMessage:
1508class TestNameManagementMessage:
1509    """This will test the mandatory NAME Management commands"""
1510
1511    @pytest.mark.profile
1512    def test_name_management_command(self) -> None:
1513        """
1514        | Description          | Test Name Management Command                                     |
1515        | :------------------- | :--------------------------------------------------------------- |
1516        | GitHub Issue         | turnaroundfactor/BMS-HW-Test#389                                 |
1517        | Instructions         | 1. Get NAME information from Address Claimed                </br>\
1518                                 2. Change ECU value                                         </br>\
1519                                 3. Test Name Management Command                             </br>\
1520                                 4. Check Values updated                                     </br>\
1521                                 5. Log results                                                   |
1522        | Estimated Duration   | 21 seconds                                                       |
1523        """
1524
1525        old_ecu_value = 0
1526        new_ecu_value = 0
1527        checksum_value = 0
1528        adopt_name_request_data: list[float] = [255, 1, 1, 1, 1, 1, 1, 1, 1, 7, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1]
1529        address_request = CANFrame(pgn=PGN["Request"], data=[PGN["Address Claimed"].id])
1530
1531        with CANBus(BATTERY_CHANNEL) as bus:
1532            entered_maintenance_mode = na_maintenance_mode(bus)
1533            if not entered_maintenance_mode:
1534                _GENERATED_PROFILE.name_management_ecu_value = BadStates.NO_RESPONSE.name
1535                _GENERATED_PROFILE.name_management_ecu_specification = "invalid"
1536
1537            # Name Management Test
1538            if address_claimed := bus.process_call(address_request):
1539                if address_claimed.pgn.id != PGN["Address Claimed", [32]].id:
1540                    Errors.unexpected_packet("Address Claimed", address_claimed)
1541                    message = f"Unexpected packet PGN {address_claimed.pgn.id} was received"
1542                    logger.write_warning_to_html_report(message)
1543                    _GENERATED_PROFILE.name_management_ecu_value = BadStates.WRONG_PACKETS.name
1544                    _GENERATED_PROFILE.name_management_ecu_specification = "invalid"
1545                    return
1546
1547                checksum_value = sum(address_claimed.packed_data) & 0xFF
1548                old_ecu_value = address_claimed.data[2]
1549                if old_ecu_value > 0:
1550                    new_ecu_value = 0
1551                else:
1552                    new_ecu_value = 1
1553            else:
1554                message = f"No response received for PGN {[PGN['Address Claimed', [32]].id]}"
1555                logger.write_warning_to_html_report(message)
1556                _GENERATED_PROFILE.name_management_ecu_value = BadStates.NO_RESPONSE.name
1557                _GENERATED_PROFILE.name_management_ecu_specification = "invalid"
1558                return
1559
1560            if not checksum_value:
1561                message = "Could not condense Name into bits for NAME Management"
1562                logger.write_warning_to_html_report(message)
1563                _GENERATED_PROFILE.name_management_ecu_value = BadStates.INVALID_RESPONSE.name
1564                _GENERATED_PROFILE.name_management_ecu_specification = "invalid"
1565                return
1566
1567            request_data: list[float] = [
1568                checksum_value,
1569                1,
1570                0,
1571                1,
1572                1,
1573                1,
1574                1,
1575                1,
1576                1,
1577                0,
1578                1,
1579                1,
1580                new_ecu_value,
1581                1,
1582                1,
1583                1,
1584                1,
1585                1,
1586                1,
1587                1,
1588            ]
1589            name_management_set_name = CANFrame(
1590                destination_address=_GENERATED_PROFILE.address, pgn=PGN["NAME Management Message"], data=request_data
1591            )
1592
1593            if pending_response := bus.process_call(name_management_set_name):
1594                if pending_response.pgn.id != PGN["NAME Management Message", [32]].id:
1595                    if pending_response.pgn.id == PGN["Acknowledgement", [32]].id:
1596                        message = (
1597                            f"Battery may not support NAME Management, received PGN "
1598                            f"{pending_response.pgn.id} ({spn_types.acknowledgement(pending_response.data[0])})"
1599                        )
1600                        logger.write_warning_to_html_report(message)
1601                        _GENERATED_PROFILE.name_management_ecu_value = BadStates.NACK.name
1602                        _GENERATED_PROFILE.name_management_ecu_specification = "invalid"
1603
1604                    else:
1605                        Errors.unexpected_packet("NAME Management Message", pending_response)
1606                        message = f"Unexpected PGN {pending_response.pgn.id} received"
1607                        logger.write_warning_to_html_report(message)
1608                        _GENERATED_PROFILE.name_management_ecu_value = BadStates.WRONG_PGN.name
1609                        _GENERATED_PROFILE.name_management_ecu_specification = "invalid"
1610
1611                    return
1612
1613                if pending_response.data[0] == 3:
1614                    message = (
1615                        f"Message was unsuccessful, received error: "
1616                        f"{spn_types.name_error_code(pending_response.data[0])}"
1617                    )
1618                    logger.write_warning_to_html_report(message)
1619                    _GENERATED_PROFILE.name_management_ecu_value = BadStates.INVALID_RESPONSE.name
1620                    _GENERATED_PROFILE.name_management_ecu_specification = "invalid"
1621                    return
1622
1623                if pending_response.data[12] != new_ecu_value:
1624                    message = f"ECU Value was not changed from {old_ecu_value} to {new_ecu_value}"
1625                    logger.write_warning_to_html_report(message)
1626                    _GENERATED_PROFILE.name_management_ecu_value = BadStates.INVALID_RESPONSE.name
1627                    _GENERATED_PROFILE.name_management_ecu_specification = "invalid"
1628                    return
1629            else:
1630                message = (
1631                    f"No response received for PGN {PGN['NAME Management Message', [32]].id} (NAME Management Message)"
1632                )
1633                logger.write_warning_to_html_report(message)
1634                _GENERATED_PROFILE.name_management_ecu_value = BadStates.NO_RESPONSE.name
1635                _GENERATED_PROFILE.name_management_ecu_specification = "invalid"
1636                return
1637
1638            adopt_name_request = CANFrame(
1639                destination_address=_GENERATED_PROFILE.address,
1640                pgn=PGN["NAME Management Message"],
1641                data=adopt_name_request_data,
1642            )
1643
1644            bus.send_message(adopt_name_request.message())
1645
1646            current_name_request_data: list[float] = [255, 1, 1, 1, 1, 1, 1, 1, 1, 6, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1]
1647            current_name_request = CANFrame(
1648                destination_address=_GENERATED_PROFILE.address,
1649                pgn=PGN["NAME Management Message"],
1650                data=current_name_request_data,
1651            )
1652
1653            if name_management_response := bus.process_call(current_name_request):
1654                if name_management_response.data[12] != new_ecu_value:
1655                    message = (
1656                        f"Name's ECU Instance was not updated after Name Management Changes, "
1657                        f"{cmp(name_management_response.data[12], '==', new_ecu_value)}"
1658                    )
1659                    logger.write_warning_to_html_report(message)
1660                    _GENERATED_PROFILE.name_management_ecu_value = BadStates.INVALID_RESPONSE.name
1661                    _GENERATED_PROFILE.name_management_ecu_specification = "invalid"
1662                    return
1663            else:
1664                message = (
1665                    f"No response received for PGN {PGN['NAME Management Message', [32]].id} (NAME Management Message)"
1666                )
1667                logger.write_warning_to_html_report(message)
1668                _GENERATED_PROFILE.name_management_ecu_value = BadStates.NO_RESPONSE.name
1669                _GENERATED_PROFILE.name_management_ecu_specification = "invalid"
1670                return
1671
1672            if new_address_claimed := bus.process_call(address_request):
1673                if new_address_claimed.data[2] != new_ecu_value + 1:
1674                    message = (
1675                        f"Address Claimed was not updated after Name Management Changes. "
1676                        f"{cmp(new_address_claimed.data[2], '==', new_ecu_value + 1)}"
1677                    )
1678                    logger.write_warning_to_html_report(message)
1679                    _GENERATED_PROFILE.name_management_ecu_value = BadStates.INVALID_RESPONSE.name
1680                    _GENERATED_PROFILE.name_management_ecu_specification = "invalid"
1681                    return
1682
1683            _GENERATED_PROFILE.name_management_ecu_value = address_claimed.data[2]
1684            logger.write_result_to_html_report(
1685                f"Name Management Command was successful. "
1686                f"ECU Instance was changed from {old_ecu_value} to {new_ecu_value}"
1687            )
1688            _GENERATED_PROFILE.name_management_ecu_specification = "valid"
1689
1690    @pytest.mark.profile
1691    def test_wrong_name_management_data(self):
1692        """
1693        | Description          | Test Incorrect Data for NAME Management Command                  |
1694        | :------------------- | :--------------------------------------------------------------- |
1695        | GitHub Issue         | turnaroundfactor/BMS-HW-Test#389                                 |
1696        | Instructions         | 1. Provide wrong Checksum value for Name Command            </br>\
1697                                 2. Check receive Checksum error code as response            </br>\
1698                                 3. Log results                                                   |
1699        | Estimated Duration   | 1 second                                                         |
1700        """
1701        wrong_checksum = 0
1702        new_ecu_value = 0
1703
1704        current_name_request_data = [255, 1, 1, 1, 1, 1, 1, 1, 1, 6, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1]
1705        current_name_request = CANFrame(
1706            destination_address=_GENERATED_PROFILE.address,
1707            pgn=PGN["NAME Management Message"],
1708            data=current_name_request_data,
1709        )
1710
1711        with CANBus(BATTERY_CHANNEL) as bus:
1712            # Test Checksum Error -- Should return Error Code: 3
1713            in_maintenance_mode = na_maintenance_mode(bus)
1714
1715            if not in_maintenance_mode:
1716                _GENERATED_PROFILE.wrong_name_data = BadStates.NO_RESPONSE.name
1717                _GENERATED_PROFILE.wrong_name_specification = "invalid"
1718
1719            if current_name := bus.process_call(current_name_request):
1720                if current_name.pgn.id != PGN["NAME Management Message", [32]].id:
1721                    if current_name.pgn.id == PGN["Acknowledgement", [32]].id:
1722                        message = (
1723                            f"Battery may not support NAME Management, received PGN "
1724                            f"{current_name.pgn.id} ({spn_types.acknowledgement(current_name.data[0])})"
1725                        )
1726                        logger.write_warning_to_html_report(message)
1727                        _GENERATED_PROFILE.wrong_name_data = BadStates.NACK.name
1728                        _GENERATED_PROFILE.wrong_name_specification = "invalid"
1729                        return
1730                    Errors.unexpected_packet("NAME Management Message", current_name)
1731                    message = f"Unexpected packet was received: PGN {current_name.pgn.id}"
1732                    logger.write_warning_to_html_report(message)
1733                    _GENERATED_PROFILE.wrong_name_data = BadStates.WRONG_PACKETS.name
1734                    _GENERATED_PROFILE.wrong_name_specification = "invalid"
1735                    return
1736
1737                wrong_checksum = sum(current_name.packed_data) & 0xFF
1738                old_ecu_value = current_name.data[12]
1739                if old_ecu_value > 0:
1740                    new_ecu_value = 0
1741                else:
1742                    new_ecu_value = 1
1743            else:
1744                message = (
1745                    f"No response received for PGN {PGN['NAME Management Message', [32]].id} (NAME Management Message)"
1746                )
1747                logger.write_warning_to_html_report(message)
1748                _GENERATED_PROFILE.wrong_name_data = BadStates.NO_RESPONSE.name
1749                _GENERATED_PROFILE.wrong_name_specification = "invalid"
1750                return
1751
1752            request_data = [wrong_checksum, 1, 0, 1, 1, 1, 1, 1, 1, 0, 1, 1, new_ecu_value, 1, 1, 1, 1, 1, 1, 1]
1753            name_management_set_name = CANFrame(
1754                destination_address=_GENERATED_PROFILE.address, pgn=PGN["NAME Management Message"], data=request_data
1755            )
1756
1757            if pending_response := bus.process_call(name_management_set_name):
1758                if pending_response.pgn.id != PGN["NAME Management Message", [32]].id:
1759                    if pending_response.pgn.id == PGN["Acknowledgement", [32]].id:
1760                        message = (
1761                            f"Battery may not support NAME Management, received PGN {pending_response.pgn.id} "
1762                            f"({spn_types.acknowledgement(pending_response.data[0])})"
1763                        )
1764                        logger.write_warning_to_html_report(message)
1765                        _GENERATED_PROFILE.wrong_name_data = BadStates.NACK.name
1766                        _GENERATED_PROFILE.wrong_name_specification = "invalid"
1767                        return
1768
1769                    Errors.unexpected_packet("NAME Management Message", pending_response)
1770                    message = f"Unexpected packet was received: {pending_response.pgn.id}"
1771                    logger.write_warning_to_html_report(message)
1772                    _GENERATED_PROFILE.wrong_name_data = BadStates.WRONG_PGN.name
1773                    _GENERATED_PROFILE.wrong_name_specification = "invalid"
1774                    return
1775
1776                if pending_response.data[0] != 3:
1777                    message = cmp(
1778                        pending_response.data[0],
1779                        "==",
1780                        3,
1781                        f"({spn_types.name_error_code(pending_response.data[0])})",
1782                        f"({spn_types.name_error_code(3)})",
1783                    )
1784                    logger.write_warning_to_html_report(
1785                        f"PGN {PGN['NAME Management Message', [32]].id} (NAME Management Message) "
1786                        f"updated NAME Management with incorrect checksum value"
1787                    )
1788                    logger.write_warning_to_html_report(message)
1789                    _GENERATED_PROFILE.wrong_name_data = "Incorrect checksum value"
1790                    _GENERATED_PROFILE.wrong_name_specification = "invalid"
1791                    return
1792            else:
1793                message = (
1794                    f"No response received for PGN {PGN['NAME Management Message', [32]].id} (NAME Management Message)"
1795                )
1796                logger.write_warning_to_html_report(message)
1797                _GENERATED_PROFILE.wrong_name_data = BadStates.NO_RESPONSE.name
1798                _GENERATED_PROFILE.wrong_name_specification = "invalid"
1799                return
1800
1801            logger.write_result_to_html_report(
1802                "NAME Management successfully did not process request with incorrect checksum value"
1803            )
1804            _GENERATED_PROFILE.wrong_name_data = "Passed"
1805            _GENERATED_PROFILE.wrong_name_specification = "valid"
1806
1807    def test_name_comparison(self) -> None:
1808        """
1809        | Description          | Compare if Name Management ECU values were changed               |
1810        | :------------------- | :--------------------------------------------------------------- |
1811        | GitHub Issue         | turnaroundfactor/BMS-HW-Test#393                                 |
1812        | Instructions         | 1. Check if Name Management ECU values matched in profiles       |
1813        | Pass / Fail Criteria | Pass if Name Management ECU values match                         |
1814        | Estimated Duration   | 1 second                                                         |
1815        """
1816        failed_test = False
1817        if not hasattr(_GENERATED_PROFILE, "name_management_ecu_value") or not hasattr(
1818            _COMPARISON_PROFILE, "name_management_ecu_value"
1819        ):
1820            message = "Nothing to compare. The profile test may have failed or been skipped"
1821            logger.write_result_to_html_report(message)
1822            pytest.skip(message)
1823
1824        comparison = cmp(
1825            _GENERATED_PROFILE.name_management_ecu_value, "==", _COMPARISON_PROFILE.name_management_ecu_value
1826        )
1827
1828        if _GENERATED_PROFILE.name_management_ecu_value != _COMPARISON_PROFILE.name_management_ecu_value:
1829            comparison = cmp(
1830                _GENERATED_PROFILE.name_management_ecu_value, "==", _COMPARISON_PROFILE.name_management_ecu_value
1831            )
1832            message = f"Name Management ECU values did not match: {comparison}"
1833            logger.write_failure_to_html_report(message)
1834            failed_test = True
1835
1836        logger.write_result_to_html_report(f"Name Management ECU values matched: {comparison}")
1837
1838        if not hasattr(_GENERATED_PROFILE, "name_management_ecu_specification") or not hasattr(
1839            _COMPARISON_PROFILE, "name_management_ecu_specification"
1840        ):
1841            logger.write_warning_to_html_report("Missing specification information")
1842        else:
1843            comparison = cmp(
1844                _GENERATED_PROFILE.name_management_ecu_specification,
1845                "==",
1846                _COMPARISON_PROFILE.name_management_ecu_specification,
1847            )
1848            if (
1849                _GENERATED_PROFILE.name_management_ecu_specification
1850                != _COMPARISON_PROFILE.name_management_ecu_specification
1851            ):
1852                logger.write_failure_to_html_report(f"Specification comparison failed: {comparison}")
1853                failed_test = True
1854            else:
1855                logger.write_result_to_html_report(f"Specification comparison passed: {comparison}")
1856
1857        if failed_test:
1858            message = "Comparisons did not match in profiles"
1859            logger.write_failure_to_html_report(message)
1860            pytest.fail(message)
1861
1862    def test_wrong_name_comparison(self) -> None:
1863        """
1864        | Description          | Compare if Name Management values were changed with wrong data   |
1865        | :------------------- | :--------------------------------------------------------------- |
1866        | GitHub Issue         | turnaroundfactor/BMS-HW-Test#393                                 |
1867        | Instructions         | 1. Check if Name Management ECU values matched in profiles       |
1868        | Pass / Fail Criteria | Pass if Name Management ECU values match                         |
1869        | Estimated Duration   | 1 second                                                         |
1870        """
1871        failed_test = False
1872        if not hasattr(_GENERATED_PROFILE, "wrong_name_data") or not hasattr(_COMPARISON_PROFILE, "wrong_name_data"):
1873            message = "Nothing to compare. The profile test may have failed or been skipped"
1874            logger.write_result_to_html_report(message)
1875            pytest.skip(message)
1876
1877        if _GENERATED_PROFILE.wrong_name_data != _COMPARISON_PROFILE.wrong_name_data:
1878            comparison = f"{_GENERATED_PROFILE.wrong_name_data}{ _COMPARISON_PROFILE.wrong_name_data}"
1879            message = f"Name Management values did not match from Wrong Name Management test: {comparison}"
1880            logger.write_failure_to_html_report(message)
1881            failed_test = True
1882        else:
1883            logger.write_result_to_html_report(
1884                f"Name Management values had same results from Wrong Name Management test: "
1885                f"{_GENERATED_PROFILE.wrong_name_data}"
1886            )
1887
1888        if not hasattr(_GENERATED_PROFILE, "wrong_name_specification") or not hasattr(
1889            _COMPARISON_PROFILE, "wrong_name_specification"
1890        ):
1891            logger.write_warning_to_html_report("Missing specification information")
1892        else:
1893            comparison = cmp(
1894                _GENERATED_PROFILE.wrong_name_specification,
1895                "==",
1896                _COMPARISON_PROFILE.wrong_name_specification,
1897            )
1898            if _GENERATED_PROFILE.wrong_name_specification != _COMPARISON_PROFILE.wrong_name_specification:
1899                logger.write_failure_to_html_report(f"Specification comparison failed: {comparison}")
1900                failed_test = True
1901            else:
1902                logger.write_result_to_html_report(f"Specification comparison passed: {comparison}")
1903
1904        if failed_test:
1905            message = "Comparisons did not match in profiles"
1906            logger.write_failure_to_html_report(message)
1907            pytest.fail(message)

This will test the mandatory NAME Management commands

@pytest.mark.profile
def test_name_management_command(self) -> None:
1511    @pytest.mark.profile
1512    def test_name_management_command(self) -> None:
1513        """
1514        | Description          | Test Name Management Command                                     |
1515        | :------------------- | :--------------------------------------------------------------- |
1516        | GitHub Issue         | turnaroundfactor/BMS-HW-Test#389                                 |
1517        | Instructions         | 1. Get NAME information from Address Claimed                </br>\
1518                                 2. Change ECU value                                         </br>\
1519                                 3. Test Name Management Command                             </br>\
1520                                 4. Check Values updated                                     </br>\
1521                                 5. Log results                                                   |
1522        | Estimated Duration   | 21 seconds                                                       |
1523        """
1524
1525        old_ecu_value = 0
1526        new_ecu_value = 0
1527        checksum_value = 0
1528        adopt_name_request_data: list[float] = [255, 1, 1, 1, 1, 1, 1, 1, 1, 7, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1]
1529        address_request = CANFrame(pgn=PGN["Request"], data=[PGN["Address Claimed"].id])
1530
1531        with CANBus(BATTERY_CHANNEL) as bus:
1532            entered_maintenance_mode = na_maintenance_mode(bus)
1533            if not entered_maintenance_mode:
1534                _GENERATED_PROFILE.name_management_ecu_value = BadStates.NO_RESPONSE.name
1535                _GENERATED_PROFILE.name_management_ecu_specification = "invalid"
1536
1537            # Name Management Test
1538            if address_claimed := bus.process_call(address_request):
1539                if address_claimed.pgn.id != PGN["Address Claimed", [32]].id:
1540                    Errors.unexpected_packet("Address Claimed", address_claimed)
1541                    message = f"Unexpected packet PGN {address_claimed.pgn.id} was received"
1542                    logger.write_warning_to_html_report(message)
1543                    _GENERATED_PROFILE.name_management_ecu_value = BadStates.WRONG_PACKETS.name
1544                    _GENERATED_PROFILE.name_management_ecu_specification = "invalid"
1545                    return
1546
1547                checksum_value = sum(address_claimed.packed_data) & 0xFF
1548                old_ecu_value = address_claimed.data[2]
1549                if old_ecu_value > 0:
1550                    new_ecu_value = 0
1551                else:
1552                    new_ecu_value = 1
1553            else:
1554                message = f"No response received for PGN {[PGN['Address Claimed', [32]].id]}"
1555                logger.write_warning_to_html_report(message)
1556                _GENERATED_PROFILE.name_management_ecu_value = BadStates.NO_RESPONSE.name
1557                _GENERATED_PROFILE.name_management_ecu_specification = "invalid"
1558                return
1559
1560            if not checksum_value:
1561                message = "Could not condense Name into bits for NAME Management"
1562                logger.write_warning_to_html_report(message)
1563                _GENERATED_PROFILE.name_management_ecu_value = BadStates.INVALID_RESPONSE.name
1564                _GENERATED_PROFILE.name_management_ecu_specification = "invalid"
1565                return
1566
1567            request_data: list[float] = [
1568                checksum_value,
1569                1,
1570                0,
1571                1,
1572                1,
1573                1,
1574                1,
1575                1,
1576                1,
1577                0,
1578                1,
1579                1,
1580                new_ecu_value,
1581                1,
1582                1,
1583                1,
1584                1,
1585                1,
1586                1,
1587                1,
1588            ]
1589            name_management_set_name = CANFrame(
1590                destination_address=_GENERATED_PROFILE.address, pgn=PGN["NAME Management Message"], data=request_data
1591            )
1592
1593            if pending_response := bus.process_call(name_management_set_name):
1594                if pending_response.pgn.id != PGN["NAME Management Message", [32]].id:
1595                    if pending_response.pgn.id == PGN["Acknowledgement", [32]].id:
1596                        message = (
1597                            f"Battery may not support NAME Management, received PGN "
1598                            f"{pending_response.pgn.id} ({spn_types.acknowledgement(pending_response.data[0])})"
1599                        )
1600                        logger.write_warning_to_html_report(message)
1601                        _GENERATED_PROFILE.name_management_ecu_value = BadStates.NACK.name
1602                        _GENERATED_PROFILE.name_management_ecu_specification = "invalid"
1603
1604                    else:
1605                        Errors.unexpected_packet("NAME Management Message", pending_response)
1606                        message = f"Unexpected PGN {pending_response.pgn.id} received"
1607                        logger.write_warning_to_html_report(message)
1608                        _GENERATED_PROFILE.name_management_ecu_value = BadStates.WRONG_PGN.name
1609                        _GENERATED_PROFILE.name_management_ecu_specification = "invalid"
1610
1611                    return
1612
1613                if pending_response.data[0] == 3:
1614                    message = (
1615                        f"Message was unsuccessful, received error: "
1616                        f"{spn_types.name_error_code(pending_response.data[0])}"
1617                    )
1618                    logger.write_warning_to_html_report(message)
1619                    _GENERATED_PROFILE.name_management_ecu_value = BadStates.INVALID_RESPONSE.name
1620                    _GENERATED_PROFILE.name_management_ecu_specification = "invalid"
1621                    return
1622
1623                if pending_response.data[12] != new_ecu_value:
1624                    message = f"ECU Value was not changed from {old_ecu_value} to {new_ecu_value}"
1625                    logger.write_warning_to_html_report(message)
1626                    _GENERATED_PROFILE.name_management_ecu_value = BadStates.INVALID_RESPONSE.name
1627                    _GENERATED_PROFILE.name_management_ecu_specification = "invalid"
1628                    return
1629            else:
1630                message = (
1631                    f"No response received for PGN {PGN['NAME Management Message', [32]].id} (NAME Management Message)"
1632                )
1633                logger.write_warning_to_html_report(message)
1634                _GENERATED_PROFILE.name_management_ecu_value = BadStates.NO_RESPONSE.name
1635                _GENERATED_PROFILE.name_management_ecu_specification = "invalid"
1636                return
1637
1638            adopt_name_request = CANFrame(
1639                destination_address=_GENERATED_PROFILE.address,
1640                pgn=PGN["NAME Management Message"],
1641                data=adopt_name_request_data,
1642            )
1643
1644            bus.send_message(adopt_name_request.message())
1645
1646            current_name_request_data: list[float] = [255, 1, 1, 1, 1, 1, 1, 1, 1, 6, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1]
1647            current_name_request = CANFrame(
1648                destination_address=_GENERATED_PROFILE.address,
1649                pgn=PGN["NAME Management Message"],
1650                data=current_name_request_data,
1651            )
1652
1653            if name_management_response := bus.process_call(current_name_request):
1654                if name_management_response.data[12] != new_ecu_value:
1655                    message = (
1656                        f"Name's ECU Instance was not updated after Name Management Changes, "
1657                        f"{cmp(name_management_response.data[12], '==', new_ecu_value)}"
1658                    )
1659                    logger.write_warning_to_html_report(message)
1660                    _GENERATED_PROFILE.name_management_ecu_value = BadStates.INVALID_RESPONSE.name
1661                    _GENERATED_PROFILE.name_management_ecu_specification = "invalid"
1662                    return
1663            else:
1664                message = (
1665                    f"No response received for PGN {PGN['NAME Management Message', [32]].id} (NAME Management Message)"
1666                )
1667                logger.write_warning_to_html_report(message)
1668                _GENERATED_PROFILE.name_management_ecu_value = BadStates.NO_RESPONSE.name
1669                _GENERATED_PROFILE.name_management_ecu_specification = "invalid"
1670                return
1671
1672            if new_address_claimed := bus.process_call(address_request):
1673                if new_address_claimed.data[2] != new_ecu_value + 1:
1674                    message = (
1675                        f"Address Claimed was not updated after Name Management Changes. "
1676                        f"{cmp(new_address_claimed.data[2], '==', new_ecu_value + 1)}"
1677                    )
1678                    logger.write_warning_to_html_report(message)
1679                    _GENERATED_PROFILE.name_management_ecu_value = BadStates.INVALID_RESPONSE.name
1680                    _GENERATED_PROFILE.name_management_ecu_specification = "invalid"
1681                    return
1682
1683            _GENERATED_PROFILE.name_management_ecu_value = address_claimed.data[2]
1684            logger.write_result_to_html_report(
1685                f"Name Management Command was successful. "
1686                f"ECU Instance was changed from {old_ecu_value} to {new_ecu_value}"
1687            )
1688            _GENERATED_PROFILE.name_management_ecu_specification = "valid"
Description Test Name Management Command
GitHub Issue turnaroundfactor/BMS-HW-Test#389
Instructions 1. Get NAME information from Address Claimed
2. Change ECU value
3. Test Name Management Command
4. Check Values updated
5. Log results
Estimated Duration 21 seconds
@pytest.mark.profile
def test_wrong_name_management_data(self):
1690    @pytest.mark.profile
1691    def test_wrong_name_management_data(self):
1692        """
1693        | Description          | Test Incorrect Data for NAME Management Command                  |
1694        | :------------------- | :--------------------------------------------------------------- |
1695        | GitHub Issue         | turnaroundfactor/BMS-HW-Test#389                                 |
1696        | Instructions         | 1. Provide wrong Checksum value for Name Command            </br>\
1697                                 2. Check receive Checksum error code as response            </br>\
1698                                 3. Log results                                                   |
1699        | Estimated Duration   | 1 second                                                         |
1700        """
1701        wrong_checksum = 0
1702        new_ecu_value = 0
1703
1704        current_name_request_data = [255, 1, 1, 1, 1, 1, 1, 1, 1, 6, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1]
1705        current_name_request = CANFrame(
1706            destination_address=_GENERATED_PROFILE.address,
1707            pgn=PGN["NAME Management Message"],
1708            data=current_name_request_data,
1709        )
1710
1711        with CANBus(BATTERY_CHANNEL) as bus:
1712            # Test Checksum Error -- Should return Error Code: 3
1713            in_maintenance_mode = na_maintenance_mode(bus)
1714
1715            if not in_maintenance_mode:
1716                _GENERATED_PROFILE.wrong_name_data = BadStates.NO_RESPONSE.name
1717                _GENERATED_PROFILE.wrong_name_specification = "invalid"
1718
1719            if current_name := bus.process_call(current_name_request):
1720                if current_name.pgn.id != PGN["NAME Management Message", [32]].id:
1721                    if current_name.pgn.id == PGN["Acknowledgement", [32]].id:
1722                        message = (
1723                            f"Battery may not support NAME Management, received PGN "
1724                            f"{current_name.pgn.id} ({spn_types.acknowledgement(current_name.data[0])})"
1725                        )
1726                        logger.write_warning_to_html_report(message)
1727                        _GENERATED_PROFILE.wrong_name_data = BadStates.NACK.name
1728                        _GENERATED_PROFILE.wrong_name_specification = "invalid"
1729                        return
1730                    Errors.unexpected_packet("NAME Management Message", current_name)
1731                    message = f"Unexpected packet was received: PGN {current_name.pgn.id}"
1732                    logger.write_warning_to_html_report(message)
1733                    _GENERATED_PROFILE.wrong_name_data = BadStates.WRONG_PACKETS.name
1734                    _GENERATED_PROFILE.wrong_name_specification = "invalid"
1735                    return
1736
1737                wrong_checksum = sum(current_name.packed_data) & 0xFF
1738                old_ecu_value = current_name.data[12]
1739                if old_ecu_value > 0:
1740                    new_ecu_value = 0
1741                else:
1742                    new_ecu_value = 1
1743            else:
1744                message = (
1745                    f"No response received for PGN {PGN['NAME Management Message', [32]].id} (NAME Management Message)"
1746                )
1747                logger.write_warning_to_html_report(message)
1748                _GENERATED_PROFILE.wrong_name_data = BadStates.NO_RESPONSE.name
1749                _GENERATED_PROFILE.wrong_name_specification = "invalid"
1750                return
1751
1752            request_data = [wrong_checksum, 1, 0, 1, 1, 1, 1, 1, 1, 0, 1, 1, new_ecu_value, 1, 1, 1, 1, 1, 1, 1]
1753            name_management_set_name = CANFrame(
1754                destination_address=_GENERATED_PROFILE.address, pgn=PGN["NAME Management Message"], data=request_data
1755            )
1756
1757            if pending_response := bus.process_call(name_management_set_name):
1758                if pending_response.pgn.id != PGN["NAME Management Message", [32]].id:
1759                    if pending_response.pgn.id == PGN["Acknowledgement", [32]].id:
1760                        message = (
1761                            f"Battery may not support NAME Management, received PGN {pending_response.pgn.id} "
1762                            f"({spn_types.acknowledgement(pending_response.data[0])})"
1763                        )
1764                        logger.write_warning_to_html_report(message)
1765                        _GENERATED_PROFILE.wrong_name_data = BadStates.NACK.name
1766                        _GENERATED_PROFILE.wrong_name_specification = "invalid"
1767                        return
1768
1769                    Errors.unexpected_packet("NAME Management Message", pending_response)
1770                    message = f"Unexpected packet was received: {pending_response.pgn.id}"
1771                    logger.write_warning_to_html_report(message)
1772                    _GENERATED_PROFILE.wrong_name_data = BadStates.WRONG_PGN.name
1773                    _GENERATED_PROFILE.wrong_name_specification = "invalid"
1774                    return
1775
1776                if pending_response.data[0] != 3:
1777                    message = cmp(
1778                        pending_response.data[0],
1779                        "==",
1780                        3,
1781                        f"({spn_types.name_error_code(pending_response.data[0])})",
1782                        f"({spn_types.name_error_code(3)})",
1783                    )
1784                    logger.write_warning_to_html_report(
1785                        f"PGN {PGN['NAME Management Message', [32]].id} (NAME Management Message) "
1786                        f"updated NAME Management with incorrect checksum value"
1787                    )
1788                    logger.write_warning_to_html_report(message)
1789                    _GENERATED_PROFILE.wrong_name_data = "Incorrect checksum value"
1790                    _GENERATED_PROFILE.wrong_name_specification = "invalid"
1791                    return
1792            else:
1793                message = (
1794                    f"No response received for PGN {PGN['NAME Management Message', [32]].id} (NAME Management Message)"
1795                )
1796                logger.write_warning_to_html_report(message)
1797                _GENERATED_PROFILE.wrong_name_data = BadStates.NO_RESPONSE.name
1798                _GENERATED_PROFILE.wrong_name_specification = "invalid"
1799                return
1800
1801            logger.write_result_to_html_report(
1802                "NAME Management successfully did not process request with incorrect checksum value"
1803            )
1804            _GENERATED_PROFILE.wrong_name_data = "Passed"
1805            _GENERATED_PROFILE.wrong_name_specification = "valid"
Description Test Incorrect Data for NAME Management Command
GitHub Issue turnaroundfactor/BMS-HW-Test#389
Instructions 1. Provide wrong Checksum value for Name Command
2. Check receive Checksum error code as response
3. Log results
Estimated Duration 1 second
def test_name_comparison(self) -> None:
1807    def test_name_comparison(self) -> None:
1808        """
1809        | Description          | Compare if Name Management ECU values were changed               |
1810        | :------------------- | :--------------------------------------------------------------- |
1811        | GitHub Issue         | turnaroundfactor/BMS-HW-Test#393                                 |
1812        | Instructions         | 1. Check if Name Management ECU values matched in profiles       |
1813        | Pass / Fail Criteria | Pass if Name Management ECU values match                         |
1814        | Estimated Duration   | 1 second                                                         |
1815        """
1816        failed_test = False
1817        if not hasattr(_GENERATED_PROFILE, "name_management_ecu_value") or not hasattr(
1818            _COMPARISON_PROFILE, "name_management_ecu_value"
1819        ):
1820            message = "Nothing to compare. The profile test may have failed or been skipped"
1821            logger.write_result_to_html_report(message)
1822            pytest.skip(message)
1823
1824        comparison = cmp(
1825            _GENERATED_PROFILE.name_management_ecu_value, "==", _COMPARISON_PROFILE.name_management_ecu_value
1826        )
1827
1828        if _GENERATED_PROFILE.name_management_ecu_value != _COMPARISON_PROFILE.name_management_ecu_value:
1829            comparison = cmp(
1830                _GENERATED_PROFILE.name_management_ecu_value, "==", _COMPARISON_PROFILE.name_management_ecu_value
1831            )
1832            message = f"Name Management ECU values did not match: {comparison}"
1833            logger.write_failure_to_html_report(message)
1834            failed_test = True
1835
1836        logger.write_result_to_html_report(f"Name Management ECU values matched: {comparison}")
1837
1838        if not hasattr(_GENERATED_PROFILE, "name_management_ecu_specification") or not hasattr(
1839            _COMPARISON_PROFILE, "name_management_ecu_specification"
1840        ):
1841            logger.write_warning_to_html_report("Missing specification information")
1842        else:
1843            comparison = cmp(
1844                _GENERATED_PROFILE.name_management_ecu_specification,
1845                "==",
1846                _COMPARISON_PROFILE.name_management_ecu_specification,
1847            )
1848            if (
1849                _GENERATED_PROFILE.name_management_ecu_specification
1850                != _COMPARISON_PROFILE.name_management_ecu_specification
1851            ):
1852                logger.write_failure_to_html_report(f"Specification comparison failed: {comparison}")
1853                failed_test = True
1854            else:
1855                logger.write_result_to_html_report(f"Specification comparison passed: {comparison}")
1856
1857        if failed_test:
1858            message = "Comparisons did not match in profiles"
1859            logger.write_failure_to_html_report(message)
1860            pytest.fail(message)
Description Compare if Name Management ECU values were changed
GitHub Issue turnaroundfactor/BMS-HW-Test#393
Instructions 1. Check if Name Management ECU values matched in profiles
Pass / Fail Criteria Pass if Name Management ECU values match
Estimated Duration 1 second
def test_wrong_name_comparison(self) -> None:
1862    def test_wrong_name_comparison(self) -> None:
1863        """
1864        | Description          | Compare if Name Management values were changed with wrong data   |
1865        | :------------------- | :--------------------------------------------------------------- |
1866        | GitHub Issue         | turnaroundfactor/BMS-HW-Test#393                                 |
1867        | Instructions         | 1. Check if Name Management ECU values matched in profiles       |
1868        | Pass / Fail Criteria | Pass if Name Management ECU values match                         |
1869        | Estimated Duration   | 1 second                                                         |
1870        """
1871        failed_test = False
1872        if not hasattr(_GENERATED_PROFILE, "wrong_name_data") or not hasattr(_COMPARISON_PROFILE, "wrong_name_data"):
1873            message = "Nothing to compare. The profile test may have failed or been skipped"
1874            logger.write_result_to_html_report(message)
1875            pytest.skip(message)
1876
1877        if _GENERATED_PROFILE.wrong_name_data != _COMPARISON_PROFILE.wrong_name_data:
1878            comparison = f"{_GENERATED_PROFILE.wrong_name_data}{ _COMPARISON_PROFILE.wrong_name_data}"
1879            message = f"Name Management values did not match from Wrong Name Management test: {comparison}"
1880            logger.write_failure_to_html_report(message)
1881            failed_test = True
1882        else:
1883            logger.write_result_to_html_report(
1884                f"Name Management values had same results from Wrong Name Management test: "
1885                f"{_GENERATED_PROFILE.wrong_name_data}"
1886            )
1887
1888        if not hasattr(_GENERATED_PROFILE, "wrong_name_specification") or not hasattr(
1889            _COMPARISON_PROFILE, "wrong_name_specification"
1890        ):
1891            logger.write_warning_to_html_report("Missing specification information")
1892        else:
1893            comparison = cmp(
1894                _GENERATED_PROFILE.wrong_name_specification,
1895                "==",
1896                _COMPARISON_PROFILE.wrong_name_specification,
1897            )
1898            if _GENERATED_PROFILE.wrong_name_specification != _COMPARISON_PROFILE.wrong_name_specification:
1899                logger.write_failure_to_html_report(f"Specification comparison failed: {comparison}")
1900                failed_test = True
1901            else:
1902                logger.write_result_to_html_report(f"Specification comparison passed: {comparison}")
1903
1904        if failed_test:
1905            message = "Comparisons did not match in profiles"
1906            logger.write_failure_to_html_report(message)
1907            pytest.fail(message)
Description Compare if Name Management values were changed with wrong data
GitHub Issue turnaroundfactor/BMS-HW-Test#393
Instructions 1. Check if Name Management ECU values matched in profiles
Pass / Fail Criteria Pass if Name Management ECU values match
Estimated Duration 1 second
class TestActiveDiagnosticTroubleCodes:
1910class TestActiveDiagnosticTroubleCodes:
1911    """Test that Active Diagnostic Trouble Codes are J1939 Compliant"""
1912
1913    @pytest.mark.profile
1914    def test_profile(self) -> None:
1915        """
1916        | Description          | Test Active Diagnostic Trouble Codes (DM1)                       |
1917        | :------------------- | :--------------------------------------------------------------- |
1918        | GitHub Issue         | turnaroundfactor/BMS-HW-Test#389                                 |
1919        | Instructions         | 1. Request Active Diagnostic Trouble Codes                  </br>\
1920                                 2. Test Command Addresses command                           </br>\
1921                                 3. Confirm Address was updated                              </br>\
1922                                 4. Log results                                                   |
1923        | Estimated Duration   | 21 seconds                                                       |
1924        """
1925        dm1_request = CANFrame(pgn=PGN["Request"], data=[PGN["Active Diagnostic Trouble Codes", [32]].id])
1926        with CANBus(BATTERY_CHANNEL) as bus:
1927            if dm1_frame := bus.process_call(dm1_request):
1928                if dm1_frame.pgn.id != PGN["Active Diagnostic Trouble Codes", [32]].id:
1929                    Errors.unexpected_packet("Active Diagnostic Trouble Codes", dm1_frame)
1930                    message = f"Unexpected data packet PGN {dm1_frame.pgn.id} was received"
1931                    logger.write_warning_to_html_report(message)
1932                    _GENERATED_PROFILE.active_diagnostic_trouble_codes = BadStates.WRONG_PGN.name
1933                    _GENERATED_PROFILE.active_diagnostic_specification = "invalid"
1934                    return
1935
1936                logger.write_result_to_html_report(
1937                    "<span style='font-weight: bold'>Active Diagnostic Trouble Codes</span>"
1938                )
1939
1940                dm1_data = dm1_frame.data
1941                pgn_data_field = PGN["Active Diagnostic Trouble Codes"].data_field
1942
1943                for spn, elem in zip(pgn_data_field, dm1_data):
1944                    description = f"({desc})" if (desc := spn.data_type(elem)) else ""
1945                    logger.write_result_to_html_report(f"{spn.name}: {elem} {description}")
1946                    high_value = 1
1947
1948                    if spn.name in (
1949                        "Protect Lamp",
1950                        "Amber Warning Lamp",
1951                        "Red Stop Lamp",
1952                        "Malfunction Indicator Lamp",
1953                        "DTC1.SPN_Conversion_Method",
1954                    ):
1955                        high_value = 1
1956
1957                    if spn.name in (
1958                        "Flash Protect Lamp",
1959                        "Flash Amber Warning Lamp",
1960                        "Flash Red Stop Lamp",
1961                        "Flash Malfunction Indicator Lamp",
1962                    ):
1963                        high_value = 3
1964
1965                    if spn.name == "DTC1.Suspect_Parameter_Number":
1966                        high_value = 524287
1967
1968                    if spn.name == "DTC1.Failure_Mode_Identifier":
1969                        high_value = 31
1970
1971                    if spn.name == "DTC1.Occurrence_Count":
1972                        high_value = 126
1973
1974                    if not 0 <= elem <= high_value:
1975                        logger.write_warning_to_html_report(
1976                            f"{spn.name}: {cmp(elem, '>=', 0, description)} and "
1977                            f"{cmp(elem, '<=', high_value, description)}"
1978                        )
1979                        _GENERATED_PROFILE.active_diagnostic_specification = "invalid"
1980
1981                _GENERATED_PROFILE.active_diagnostic_trouble_codes = {
1982                    "protect_lamp": dm1_data[0],
1983                    "amber_warning_lamp": dm1_data[1],
1984                    "red_stop_lamp": dm1_data[2],
1985                    "malfunction_indicator_lamp": dm1_data[3],
1986                    "flash_protect_lamp": dm1_data[4],
1987                    "flash_amber_warning_lamp": dm1_data[5],
1988                    "flash_red_stop_lamp": dm1_data[6],
1989                    "flash_malfunction_indicator_lamp": dm1_data[7],
1990                    "dtc1_suspect_parameter_number": dm1_data[8],
1991                    "dtc1_failure_mode_identifier": dm1_data[9],
1992                    "dtc1_occurrence_count": dm1_data[10],
1993                    "dtc1_spn_conversion_method": dm1_data[11],
1994                }
1995
1996                if not hasattr(_GENERATED_PROFILE, "active_diagnostic_specification"):
1997                    _GENERATED_PROFILE.active_diagnostic_specification = "valid"
1998
1999            else:
2000                message = (
2001                    f"No response was received for mandatory command: "
2002                    f"PGN {PGN['Active Diagnostic Trouble Codes', [32]].id} (Active Diagnostic Trouble Codes)"
2003                )
2004                logger.write_warning_to_html_report(message)
2005                _GENERATED_PROFILE.active_diagnostic_trouble_codes = BadStates.NO_RESPONSE.name
2006                _GENERATED_PROFILE.active_diagnostic_specification = "invalid"
2007
2008    def test_comparison(self) -> None:
2009        """
2010        | Description          | Compare if Active Diagnostic Trouble Codes matched               |
2011        | :------------------- | :--------------------------------------------------------------- |
2012        | GitHub Issue         | turnaroundfactor/BMS-HW-Test#393                                 |
2013        | Instructions         | 1. Check if Active Diagnostic Trouble Codes matched in profiles  |
2014        | Pass / Fail Criteria | Pass if Active Diagnostic Trouble Codes match                    |
2015        | Estimated Duration   | 1 second                                                         |
2016        """
2017        failed_test = False
2018        if not hasattr(_GENERATED_PROFILE, "active_diagnostic_trouble_codes") or not hasattr(
2019            _COMPARISON_PROFILE, "active_diagnostic_trouble_codes"
2020        ):
2021            message = "Nothing to compare. The profile test may have failed or been skipped"
2022            logger.write_result_to_html_report(message)
2023            pytest.skip(message)
2024
2025        if isinstance(_GENERATED_PROFILE.active_diagnostic_trouble_codes, str) or isinstance(
2026            _COMPARISON_PROFILE.active_diagnostic_trouble_codes, str
2027        ):
2028            if not isinstance(
2029                _GENERATED_PROFILE.active_diagnostic_trouble_codes,
2030                type(_COMPARISON_PROFILE.active_diagnostic_trouble_codes),
2031            ):
2032                message = "Unable to compare profiles, Active Diagnostic Trouble Codes types did not match"
2033                logger.write_failure_to_html_report(message)
2034                pytest.fail(message)
2035
2036            comparison = cmp(
2037                _GENERATED_PROFILE.active_diagnostic_trouble_codes,
2038                "==",
2039                _COMPARISON_PROFILE.active_diagnostic_trouble_codes,
2040            )
2041
2042            if (
2043                _GENERATED_PROFILE.active_diagnostic_trouble_codes
2044                != _COMPARISON_PROFILE.active_diagnostic_trouble_codes
2045            ):
2046                message = f"Active Diagnostic Trouble Codes did not match: {comparison}"
2047                logger.write_failure_to_html_report(message)
2048                pytest.fail(message)
2049
2050            message = f"Active Diagnostic Trouble Codes matched: {comparison}"
2051            logger.write_result_to_html_report(message)
2052        else:
2053            failed_categories = []
2054            for key, value in _GENERATED_PROFILE.active_diagnostic_trouble_codes.items():
2055                key_text = key.replace("_", " ").title()
2056
2057                if key == "dtc1_suspect_parameter_number":
2058                    key_text = "DTC1.Suspect_Parameter_Number"
2059
2060                if key == "dtc1_failure_mode_identifier":
2061                    key_text = "DTC1.Failure_Mode_Identifier"
2062
2063                if key == "dtc1_occurrence_count":
2064                    key_text = "DTC1.Occurrence_Count"
2065
2066                if key == "dtc1_spn_conversion_method":
2067                    key_text = "DTC1.SPN_Conversion_Method"
2068
2069                comparison = cmp(value, "==", _COMPARISON_PROFILE.active_diagnostic_trouble_codes[key])
2070                message = f"{key_text}: {comparison}"
2071
2072                if value != _COMPARISON_PROFILE.active_diagnostic_trouble_codes[key]:
2073                    logger.write_warning_to_html_report(message)
2074                    failed_categories.append(key_text)
2075                else:
2076                    logger.write_result_to_html_report(message)
2077
2078            fail_length = len(failed_categories)
2079
2080            if fail_length > 0:
2081                categories = "data values" if fail_length > 1 else "data value"
2082                message = f"{fail_length} {categories} failed profile comparisons: {', '.join(failed_categories)}"
2083                logger.write_failure_to_html_report(message)
2084                failed_test = True
2085            else:
2086                logger.write_result_to_html_report("Active Diagnostic Trouble Code values between profiles matched")
2087
2088        if not hasattr(_GENERATED_PROFILE, "active_diagnostic_specification") or not hasattr(
2089            _COMPARISON_PROFILE, "active_diagnostic_specification"
2090        ):
2091            logger.write_warning_to_html_report("Missing specification information")
2092        else:
2093            comparison = cmp(
2094                _GENERATED_PROFILE.active_diagnostic_specification,
2095                "==",
2096                _COMPARISON_PROFILE.active_diagnostic_specification,
2097            )
2098            if (
2099                _GENERATED_PROFILE.active_diagnostic_specification
2100                != _COMPARISON_PROFILE.active_diagnostic_specification
2101            ):
2102                logger.write_failure_to_html_report(f"Specification comparison failed: {comparison}")
2103                failed_test = True
2104            else:
2105                logger.write_result_to_html_report(f"Specification comparison passed: {comparison}")
2106
2107        if failed_test:
2108            message = "Comparisons did not match in profiles"
2109            logger.write_failure_to_html_report(message)
2110            pytest.fail(message)

Test that Active Diagnostic Trouble Codes are J1939 Compliant

@pytest.mark.profile
def test_profile(self) -> None:
1913    @pytest.mark.profile
1914    def test_profile(self) -> None:
1915        """
1916        | Description          | Test Active Diagnostic Trouble Codes (DM1)                       |
1917        | :------------------- | :--------------------------------------------------------------- |
1918        | GitHub Issue         | turnaroundfactor/BMS-HW-Test#389                                 |
1919        | Instructions         | 1. Request Active Diagnostic Trouble Codes                  </br>\
1920                                 2. Test Command Addresses command                           </br>\
1921                                 3. Confirm Address was updated                              </br>\
1922                                 4. Log results                                                   |
1923        | Estimated Duration   | 21 seconds                                                       |
1924        """
1925        dm1_request = CANFrame(pgn=PGN["Request"], data=[PGN["Active Diagnostic Trouble Codes", [32]].id])
1926        with CANBus(BATTERY_CHANNEL) as bus:
1927            if dm1_frame := bus.process_call(dm1_request):
1928                if dm1_frame.pgn.id != PGN["Active Diagnostic Trouble Codes", [32]].id:
1929                    Errors.unexpected_packet("Active Diagnostic Trouble Codes", dm1_frame)
1930                    message = f"Unexpected data packet PGN {dm1_frame.pgn.id} was received"
1931                    logger.write_warning_to_html_report(message)
1932                    _GENERATED_PROFILE.active_diagnostic_trouble_codes = BadStates.WRONG_PGN.name
1933                    _GENERATED_PROFILE.active_diagnostic_specification = "invalid"
1934                    return
1935
1936                logger.write_result_to_html_report(
1937                    "<span style='font-weight: bold'>Active Diagnostic Trouble Codes</span>"
1938                )
1939
1940                dm1_data = dm1_frame.data
1941                pgn_data_field = PGN["Active Diagnostic Trouble Codes"].data_field
1942
1943                for spn, elem in zip(pgn_data_field, dm1_data):
1944                    description = f"({desc})" if (desc := spn.data_type(elem)) else ""
1945                    logger.write_result_to_html_report(f"{spn.name}: {elem} {description}")
1946                    high_value = 1
1947
1948                    if spn.name in (
1949                        "Protect Lamp",
1950                        "Amber Warning Lamp",
1951                        "Red Stop Lamp",
1952                        "Malfunction Indicator Lamp",
1953                        "DTC1.SPN_Conversion_Method",
1954                    ):
1955                        high_value = 1
1956
1957                    if spn.name in (
1958                        "Flash Protect Lamp",
1959                        "Flash Amber Warning Lamp",
1960                        "Flash Red Stop Lamp",
1961                        "Flash Malfunction Indicator Lamp",
1962                    ):
1963                        high_value = 3
1964
1965                    if spn.name == "DTC1.Suspect_Parameter_Number":
1966                        high_value = 524287
1967
1968                    if spn.name == "DTC1.Failure_Mode_Identifier":
1969                        high_value = 31
1970
1971                    if spn.name == "DTC1.Occurrence_Count":
1972                        high_value = 126
1973
1974                    if not 0 <= elem <= high_value:
1975                        logger.write_warning_to_html_report(
1976                            f"{spn.name}: {cmp(elem, '>=', 0, description)} and "
1977                            f"{cmp(elem, '<=', high_value, description)}"
1978                        )
1979                        _GENERATED_PROFILE.active_diagnostic_specification = "invalid"
1980
1981                _GENERATED_PROFILE.active_diagnostic_trouble_codes = {
1982                    "protect_lamp": dm1_data[0],
1983                    "amber_warning_lamp": dm1_data[1],
1984                    "red_stop_lamp": dm1_data[2],
1985                    "malfunction_indicator_lamp": dm1_data[3],
1986                    "flash_protect_lamp": dm1_data[4],
1987                    "flash_amber_warning_lamp": dm1_data[5],
1988                    "flash_red_stop_lamp": dm1_data[6],
1989                    "flash_malfunction_indicator_lamp": dm1_data[7],
1990                    "dtc1_suspect_parameter_number": dm1_data[8],
1991                    "dtc1_failure_mode_identifier": dm1_data[9],
1992                    "dtc1_occurrence_count": dm1_data[10],
1993                    "dtc1_spn_conversion_method": dm1_data[11],
1994                }
1995
1996                if not hasattr(_GENERATED_PROFILE, "active_diagnostic_specification"):
1997                    _GENERATED_PROFILE.active_diagnostic_specification = "valid"
1998
1999            else:
2000                message = (
2001                    f"No response was received for mandatory command: "
2002                    f"PGN {PGN['Active Diagnostic Trouble Codes', [32]].id} (Active Diagnostic Trouble Codes)"
2003                )
2004                logger.write_warning_to_html_report(message)
2005                _GENERATED_PROFILE.active_diagnostic_trouble_codes = BadStates.NO_RESPONSE.name
2006                _GENERATED_PROFILE.active_diagnostic_specification = "invalid"
Description Test Active Diagnostic Trouble Codes (DM1)
GitHub Issue turnaroundfactor/BMS-HW-Test#389
Instructions 1. Request Active Diagnostic Trouble Codes
2. Test Command Addresses command
3. Confirm Address was updated
4. Log results
Estimated Duration 21 seconds
def test_comparison(self) -> None:
2008    def test_comparison(self) -> None:
2009        """
2010        | Description          | Compare if Active Diagnostic Trouble Codes matched               |
2011        | :------------------- | :--------------------------------------------------------------- |
2012        | GitHub Issue         | turnaroundfactor/BMS-HW-Test#393                                 |
2013        | Instructions         | 1. Check if Active Diagnostic Trouble Codes matched in profiles  |
2014        | Pass / Fail Criteria | Pass if Active Diagnostic Trouble Codes match                    |
2015        | Estimated Duration   | 1 second                                                         |
2016        """
2017        failed_test = False
2018        if not hasattr(_GENERATED_PROFILE, "active_diagnostic_trouble_codes") or not hasattr(
2019            _COMPARISON_PROFILE, "active_diagnostic_trouble_codes"
2020        ):
2021            message = "Nothing to compare. The profile test may have failed or been skipped"
2022            logger.write_result_to_html_report(message)
2023            pytest.skip(message)
2024
2025        if isinstance(_GENERATED_PROFILE.active_diagnostic_trouble_codes, str) or isinstance(
2026            _COMPARISON_PROFILE.active_diagnostic_trouble_codes, str
2027        ):
2028            if not isinstance(
2029                _GENERATED_PROFILE.active_diagnostic_trouble_codes,
2030                type(_COMPARISON_PROFILE.active_diagnostic_trouble_codes),
2031            ):
2032                message = "Unable to compare profiles, Active Diagnostic Trouble Codes types did not match"
2033                logger.write_failure_to_html_report(message)
2034                pytest.fail(message)
2035
2036            comparison = cmp(
2037                _GENERATED_PROFILE.active_diagnostic_trouble_codes,
2038                "==",
2039                _COMPARISON_PROFILE.active_diagnostic_trouble_codes,
2040            )
2041
2042            if (
2043                _GENERATED_PROFILE.active_diagnostic_trouble_codes
2044                != _COMPARISON_PROFILE.active_diagnostic_trouble_codes
2045            ):
2046                message = f"Active Diagnostic Trouble Codes did not match: {comparison}"
2047                logger.write_failure_to_html_report(message)
2048                pytest.fail(message)
2049
2050            message = f"Active Diagnostic Trouble Codes matched: {comparison}"
2051            logger.write_result_to_html_report(message)
2052        else:
2053            failed_categories = []
2054            for key, value in _GENERATED_PROFILE.active_diagnostic_trouble_codes.items():
2055                key_text = key.replace("_", " ").title()
2056
2057                if key == "dtc1_suspect_parameter_number":
2058                    key_text = "DTC1.Suspect_Parameter_Number"
2059
2060                if key == "dtc1_failure_mode_identifier":
2061                    key_text = "DTC1.Failure_Mode_Identifier"
2062
2063                if key == "dtc1_occurrence_count":
2064                    key_text = "DTC1.Occurrence_Count"
2065
2066                if key == "dtc1_spn_conversion_method":
2067                    key_text = "DTC1.SPN_Conversion_Method"
2068
2069                comparison = cmp(value, "==", _COMPARISON_PROFILE.active_diagnostic_trouble_codes[key])
2070                message = f"{key_text}: {comparison}"
2071
2072                if value != _COMPARISON_PROFILE.active_diagnostic_trouble_codes[key]:
2073                    logger.write_warning_to_html_report(message)
2074                    failed_categories.append(key_text)
2075                else:
2076                    logger.write_result_to_html_report(message)
2077
2078            fail_length = len(failed_categories)
2079
2080            if fail_length > 0:
2081                categories = "data values" if fail_length > 1 else "data value"
2082                message = f"{fail_length} {categories} failed profile comparisons: {', '.join(failed_categories)}"
2083                logger.write_failure_to_html_report(message)
2084                failed_test = True
2085            else:
2086                logger.write_result_to_html_report("Active Diagnostic Trouble Code values between profiles matched")
2087
2088        if not hasattr(_GENERATED_PROFILE, "active_diagnostic_specification") or not hasattr(
2089            _COMPARISON_PROFILE, "active_diagnostic_specification"
2090        ):
2091            logger.write_warning_to_html_report("Missing specification information")
2092        else:
2093            comparison = cmp(
2094                _GENERATED_PROFILE.active_diagnostic_specification,
2095                "==",
2096                _COMPARISON_PROFILE.active_diagnostic_specification,
2097            )
2098            if (
2099                _GENERATED_PROFILE.active_diagnostic_specification
2100                != _COMPARISON_PROFILE.active_diagnostic_specification
2101            ):
2102                logger.write_failure_to_html_report(f"Specification comparison failed: {comparison}")
2103                failed_test = True
2104            else:
2105                logger.write_result_to_html_report(f"Specification comparison passed: {comparison}")
2106
2107        if failed_test:
2108            message = "Comparisons did not match in profiles"
2109            logger.write_failure_to_html_report(message)
2110            pytest.fail(message)
Description Compare if Active Diagnostic Trouble Codes matched
GitHub Issue turnaroundfactor/BMS-HW-Test#393
Instructions 1. Check if Active Diagnostic Trouble Codes matched in profiles
Pass / Fail Criteria Pass if Active Diagnostic Trouble Codes match
Estimated Duration 1 second
class TestVehicleElectricalPower:
2239class TestVehicleElectricalPower:
2240    """Test Vehicle Electrical Power command is J1939 Compliant"""
2241
2242    @pytest.mark.profile
2243    def test_profile(self) -> None:
2244        """
2245        | Description          | Test Vehicle Electrical Power #5 (VEP5) command                  |
2246        | :------------------- | :--------------------------------------------------------------- |
2247        | GitHub Issue         | turnaroundfactor/BMS-HW-Test#389                                 |
2248        | Instructions         | 1. Send Vehicle Electrical Power #5 command                 </br>\
2249                                 2. Check values in data                                     </br>\
2250                                 3. Log response                                                  |
2251        | Estimated Duration   | 21 seconds                                                       |
2252        """
2253
2254        with CANBus(BATTERY_CHANNEL) as bus:
2255            vehicle_electrical_power_request = CANFrame(
2256                destination_address=_GENERATED_PROFILE.address,
2257                pgn=PGN["Request"],
2258                data=[PGN["Vehicle Electrical Power #5"].id],
2259            )
2260            if vehicle_electrical_power_response := bus.process_call(vehicle_electrical_power_request):
2261                if vehicle_electrical_power_response.pgn.id != PGN["Vehicle Electrical Power #5", [32]].id:
2262                    if vehicle_electrical_power_response.pgn.id == 59392:
2263                        message = (
2264                            f"Received PGN {vehicle_electrical_power_response.pgn.id} "
2265                            f"({spn_types.acknowledgement(vehicle_electrical_power_response.data[0])}), "
2266                            f'not PGN {PGN["Vehicle Electrical Power #5", [32]].id} '
2267                        )
2268                        _GENERATED_PROFILE.vehicle_electrical_power = BadStates.NACK.name
2269                        _GENERATED_PROFILE.vehicle_electrical_specification = "invalid"
2270                    else:
2271                        message = (
2272                            f"Received PGN {vehicle_electrical_power_response.pgn.id} "
2273                            f"{vehicle_electrical_power_response.pgn.short_name}, "
2274                            f'not PGN {PGN["Vehicle Electrical Power #5", [32]].id} '
2275                        )
2276                        _GENERATED_PROFILE.vehicle_electrical_power = BadStates.WRONG_PGN.name
2277                        _GENERATED_PROFILE.vehicle_electrical_specification = "invalid"
2278                    logger.write_warning_to_html_report(message)
2279                    return
2280            else:
2281                message = (
2282                    f"Battery did not respond to PGN {PGN['Vehicle Electrical Power #5', [32]].id} "
2283                    f"Vehicle Electrical Power #5 request"
2284                )
2285                _GENERATED_PROFILE.vehicle_electrical_power = BadStates.NO_RESPONSE.name
2286                _GENERATED_PROFILE.vehicle_electrical_specification = "invalid"
2287                logger.write_warning_to_html_report(message)
2288                return
2289
2290            vep5_data = vehicle_electrical_power_response.data
2291            vep5_pgn = PGN["Vehicle Electrical Power #5"].data_field
2292
2293            logger.write_result_to_html_report("<span style='font-weight: bold'>Vehicle Electrical Power #5</span>")
2294
2295            for spn, elem in zip(vep5_pgn, vep5_data):
2296                if spn.name in ["Reserved", "SLI Battery Pack State of Charge"]:
2297                    continue
2298
2299                description = f"({desc})" if (desc := spn.data_type(elem)) else ""
2300                logger.write_result_to_html_report(f"{spn.name}: {elem} {description}")
2301                high_value = 1.0
2302
2303                if spn.name == "SLI Battery Pack Capacity":
2304                    high_value = 64255
2305
2306                if spn.name == "SLI Battery Pack Health":
2307                    high_value = 125
2308
2309                if spn.name == "SLI Cranking Predicted Minimum Battery Voltage":
2310                    high_value = 50
2311
2312                if not 0 <= elem <= high_value:
2313                    logger.write_warning_to_html_report(
2314                        f"{spn.name}: {cmp(elem, '>=', 0, description)} and "
2315                        f"{cmp(elem, '<=', high_value, description)}"
2316                    )
2317                    _GENERATED_PROFILE.vehicle_electrical_specification = "invalid"
2318
2319            vehicle_electrical_power = {
2320                "sli_battery_pack_capacity": vep5_data[1],
2321                "sli_battery_pack_health": vep5_data[2],
2322                "sli_cranking_predicted_minimum_battery_voltage": vep5_data[3],
2323            }
2324
2325            if not hasattr(_GENERATED_PROFILE, "vehicle_electrical_specification"):
2326                _GENERATED_PROFILE.vehicle_electrical_specification = "valid"
2327
2328            _GENERATED_PROFILE.vehicle_electrical_power = vehicle_electrical_power
2329            logger.write_result_to_html_report("Test Vehicle Electrical Power #5 command was successful")
2330
2331    def test_comparison(self) -> None:
2332        """
2333        | Description          | Compare if Vehicle Electrical Power #5 values matched            |
2334        | :------------------- | :--------------------------------------------------------------- |
2335        | GitHub Issue         | turnaroundfactor/BMS-HW-Test#393                                 |
2336        | Instructions         | 1. Check if Vehicle Electrical Power #5 matched in profiles      |
2337        | Pass / Fail Criteria | Pass if Vehicle Electrical Power #5 values match                 |
2338        | Estimated Duration   | 1 second                                                         |
2339        """
2340
2341        failed_test = False
2342        if not hasattr(_GENERATED_PROFILE, "vehicle_electrical_power") or not hasattr(
2343            _COMPARISON_PROFILE, "vehicle_electrical_power"
2344        ):
2345            message = "Nothing to compare. The profile test may have failed or been skipped"
2346            logger.write_result_to_html_report(message)
2347            pytest.skip(message)
2348
2349        if isinstance(_GENERATED_PROFILE.vehicle_electrical_power, str) or isinstance(
2350            _COMPARISON_PROFILE.vehicle_electrical_power, str
2351        ):
2352            if not isinstance(
2353                _GENERATED_PROFILE.vehicle_electrical_power, type(_COMPARISON_PROFILE.vehicle_electrical_power)
2354            ):
2355                message = "Unable to compare profiles, Vehicle Electrical Power #5 types did not match"
2356                logger.write_failure_to_html_report(message)
2357                pytest.fail(message)
2358
2359            comparison = cmp(
2360                _GENERATED_PROFILE.vehicle_electrical_power, "==", _COMPARISON_PROFILE.vehicle_electrical_power
2361            )
2362
2363            if _GENERATED_PROFILE.vehicle_electrical_power != _COMPARISON_PROFILE.vehicle_electrical_power:
2364                message = f"Vehicle Electrical Power #5 did not match: {comparison}"
2365                logger.write_failure_to_html_report(message)
2366                pytest.fail(message)
2367
2368            message = f"Vehicle Electrical Power #5 matched: {comparison}"
2369            logger.write_result_to_html_report(message)
2370        else:
2371
2372            failed_categories = []
2373            percent_closeness = 0.05
2374
2375            for key, value in _GENERATED_PROFILE.vehicle_electrical_power.items():
2376                key_text = key.replace("_", " ").title()
2377                key_text = key_text.replace("Sli", "SLI")
2378                diff = abs(value - _COMPARISON_PROFILE.vehicle_electrical_power[key])
2379                average = (value + _COMPARISON_PROFILE.vehicle_electrical_power[key]) / 2
2380                percentage = round(diff / average, 4)
2381
2382                comparison = cmp(percentage * 100, "<=", percent_closeness * 100, "%")
2383                message = f"{key_text}: {comparison}"
2384
2385                if not math.isclose(
2386                    value,
2387                    _COMPARISON_PROFILE.vehicle_electrical_power[key],
2388                    rel_tol=percent_closeness,
2389                ):
2390                    logger.write_warning_to_html_report(message)
2391                    failed_categories.append(key_text)
2392                else:
2393                    logger.write_result_to_html_report(message)
2394
2395            fail_length = len(failed_categories)
2396
2397            if fail_length > 0:
2398                categories = "data values" if fail_length > 1 else "data value"
2399                message = f"{fail_length} {categories} failed profile comparisons: {', '.join(failed_categories)}"
2400                logger.write_failure_to_html_report(message)
2401                failed_test = True
2402            else:
2403                logger.write_result_to_html_report(
2404                    f"Vehicle Electrical Power #5 values between profiles were within {percent_closeness * 100}%"
2405                )
2406
2407        if not hasattr(_GENERATED_PROFILE, "vehicle_electrical_specification") or not hasattr(
2408            _COMPARISON_PROFILE, "vehicle_electrical_specification"
2409        ):
2410            logger.write_warning_to_html_report("Missing specification information")
2411        else:
2412            comparison = cmp(
2413                _GENERATED_PROFILE.vehicle_electrical_specification,
2414                "==",
2415                _COMPARISON_PROFILE.vehicle_electrical_specification,
2416            )
2417            if (
2418                _GENERATED_PROFILE.vehicle_electrical_specification
2419                != _COMPARISON_PROFILE.vehicle_electrical_specification
2420            ):
2421                logger.write_failure_to_html_report(f"Specification comparison failed: {comparison}")
2422                failed_test = True
2423            else:
2424                logger.write_result_to_html_report(f"Specification comparison passed: {comparison}")
2425
2426        if failed_test:
2427            message = "Comparisons did not match in profiles"
2428            logger.write_failure_to_html_report(message)
2429            pytest.fail(message)

Test Vehicle Electrical Power command is J1939 Compliant

@pytest.mark.profile
def test_profile(self) -> None:
2242    @pytest.mark.profile
2243    def test_profile(self) -> None:
2244        """
2245        | Description          | Test Vehicle Electrical Power #5 (VEP5) command                  |
2246        | :------------------- | :--------------------------------------------------------------- |
2247        | GitHub Issue         | turnaroundfactor/BMS-HW-Test#389                                 |
2248        | Instructions         | 1. Send Vehicle Electrical Power #5 command                 </br>\
2249                                 2. Check values in data                                     </br>\
2250                                 3. Log response                                                  |
2251        | Estimated Duration   | 21 seconds                                                       |
2252        """
2253
2254        with CANBus(BATTERY_CHANNEL) as bus:
2255            vehicle_electrical_power_request = CANFrame(
2256                destination_address=_GENERATED_PROFILE.address,
2257                pgn=PGN["Request"],
2258                data=[PGN["Vehicle Electrical Power #5"].id],
2259            )
2260            if vehicle_electrical_power_response := bus.process_call(vehicle_electrical_power_request):
2261                if vehicle_electrical_power_response.pgn.id != PGN["Vehicle Electrical Power #5", [32]].id:
2262                    if vehicle_electrical_power_response.pgn.id == 59392:
2263                        message = (
2264                            f"Received PGN {vehicle_electrical_power_response.pgn.id} "
2265                            f"({spn_types.acknowledgement(vehicle_electrical_power_response.data[0])}), "
2266                            f'not PGN {PGN["Vehicle Electrical Power #5", [32]].id} '
2267                        )
2268                        _GENERATED_PROFILE.vehicle_electrical_power = BadStates.NACK.name
2269                        _GENERATED_PROFILE.vehicle_electrical_specification = "invalid"
2270                    else:
2271                        message = (
2272                            f"Received PGN {vehicle_electrical_power_response.pgn.id} "
2273                            f"{vehicle_electrical_power_response.pgn.short_name}, "
2274                            f'not PGN {PGN["Vehicle Electrical Power #5", [32]].id} '
2275                        )
2276                        _GENERATED_PROFILE.vehicle_electrical_power = BadStates.WRONG_PGN.name
2277                        _GENERATED_PROFILE.vehicle_electrical_specification = "invalid"
2278                    logger.write_warning_to_html_report(message)
2279                    return
2280            else:
2281                message = (
2282                    f"Battery did not respond to PGN {PGN['Vehicle Electrical Power #5', [32]].id} "
2283                    f"Vehicle Electrical Power #5 request"
2284                )
2285                _GENERATED_PROFILE.vehicle_electrical_power = BadStates.NO_RESPONSE.name
2286                _GENERATED_PROFILE.vehicle_electrical_specification = "invalid"
2287                logger.write_warning_to_html_report(message)
2288                return
2289
2290            vep5_data = vehicle_electrical_power_response.data
2291            vep5_pgn = PGN["Vehicle Electrical Power #5"].data_field
2292
2293            logger.write_result_to_html_report("<span style='font-weight: bold'>Vehicle Electrical Power #5</span>")
2294
2295            for spn, elem in zip(vep5_pgn, vep5_data):
2296                if spn.name in ["Reserved", "SLI Battery Pack State of Charge"]:
2297                    continue
2298
2299                description = f"({desc})" if (desc := spn.data_type(elem)) else ""
2300                logger.write_result_to_html_report(f"{spn.name}: {elem} {description}")
2301                high_value = 1.0
2302
2303                if spn.name == "SLI Battery Pack Capacity":
2304                    high_value = 64255
2305
2306                if spn.name == "SLI Battery Pack Health":
2307                    high_value = 125
2308
2309                if spn.name == "SLI Cranking Predicted Minimum Battery Voltage":
2310                    high_value = 50
2311
2312                if not 0 <= elem <= high_value:
2313                    logger.write_warning_to_html_report(
2314                        f"{spn.name}: {cmp(elem, '>=', 0, description)} and "
2315                        f"{cmp(elem, '<=', high_value, description)}"
2316                    )
2317                    _GENERATED_PROFILE.vehicle_electrical_specification = "invalid"
2318
2319            vehicle_electrical_power = {
2320                "sli_battery_pack_capacity": vep5_data[1],
2321                "sli_battery_pack_health": vep5_data[2],
2322                "sli_cranking_predicted_minimum_battery_voltage": vep5_data[3],
2323            }
2324
2325            if not hasattr(_GENERATED_PROFILE, "vehicle_electrical_specification"):
2326                _GENERATED_PROFILE.vehicle_electrical_specification = "valid"
2327
2328            _GENERATED_PROFILE.vehicle_electrical_power = vehicle_electrical_power
2329            logger.write_result_to_html_report("Test Vehicle Electrical Power #5 command was successful")
Description Test Vehicle Electrical Power #5 (VEP5) command
GitHub Issue turnaroundfactor/BMS-HW-Test#389
Instructions 1. Send Vehicle Electrical Power #5 command
2. Check values in data
3. Log response
Estimated Duration 21 seconds
def test_comparison(self) -> None:
2331    def test_comparison(self) -> None:
2332        """
2333        | Description          | Compare if Vehicle Electrical Power #5 values matched            |
2334        | :------------------- | :--------------------------------------------------------------- |
2335        | GitHub Issue         | turnaroundfactor/BMS-HW-Test#393                                 |
2336        | Instructions         | 1. Check if Vehicle Electrical Power #5 matched in profiles      |
2337        | Pass / Fail Criteria | Pass if Vehicle Electrical Power #5 values match                 |
2338        | Estimated Duration   | 1 second                                                         |
2339        """
2340
2341        failed_test = False
2342        if not hasattr(_GENERATED_PROFILE, "vehicle_electrical_power") or not hasattr(
2343            _COMPARISON_PROFILE, "vehicle_electrical_power"
2344        ):
2345            message = "Nothing to compare. The profile test may have failed or been skipped"
2346            logger.write_result_to_html_report(message)
2347            pytest.skip(message)
2348
2349        if isinstance(_GENERATED_PROFILE.vehicle_electrical_power, str) or isinstance(
2350            _COMPARISON_PROFILE.vehicle_electrical_power, str
2351        ):
2352            if not isinstance(
2353                _GENERATED_PROFILE.vehicle_electrical_power, type(_COMPARISON_PROFILE.vehicle_electrical_power)
2354            ):
2355                message = "Unable to compare profiles, Vehicle Electrical Power #5 types did not match"
2356                logger.write_failure_to_html_report(message)
2357                pytest.fail(message)
2358
2359            comparison = cmp(
2360                _GENERATED_PROFILE.vehicle_electrical_power, "==", _COMPARISON_PROFILE.vehicle_electrical_power
2361            )
2362
2363            if _GENERATED_PROFILE.vehicle_electrical_power != _COMPARISON_PROFILE.vehicle_electrical_power:
2364                message = f"Vehicle Electrical Power #5 did not match: {comparison}"
2365                logger.write_failure_to_html_report(message)
2366                pytest.fail(message)
2367
2368            message = f"Vehicle Electrical Power #5 matched: {comparison}"
2369            logger.write_result_to_html_report(message)
2370        else:
2371
2372            failed_categories = []
2373            percent_closeness = 0.05
2374
2375            for key, value in _GENERATED_PROFILE.vehicle_electrical_power.items():
2376                key_text = key.replace("_", " ").title()
2377                key_text = key_text.replace("Sli", "SLI")
2378                diff = abs(value - _COMPARISON_PROFILE.vehicle_electrical_power[key])
2379                average = (value + _COMPARISON_PROFILE.vehicle_electrical_power[key]) / 2
2380                percentage = round(diff / average, 4)
2381
2382                comparison = cmp(percentage * 100, "<=", percent_closeness * 100, "%")
2383                message = f"{key_text}: {comparison}"
2384
2385                if not math.isclose(
2386                    value,
2387                    _COMPARISON_PROFILE.vehicle_electrical_power[key],
2388                    rel_tol=percent_closeness,
2389                ):
2390                    logger.write_warning_to_html_report(message)
2391                    failed_categories.append(key_text)
2392                else:
2393                    logger.write_result_to_html_report(message)
2394
2395            fail_length = len(failed_categories)
2396
2397            if fail_length > 0:
2398                categories = "data values" if fail_length > 1 else "data value"
2399                message = f"{fail_length} {categories} failed profile comparisons: {', '.join(failed_categories)}"
2400                logger.write_failure_to_html_report(message)
2401                failed_test = True
2402            else:
2403                logger.write_result_to_html_report(
2404                    f"Vehicle Electrical Power #5 values between profiles were within {percent_closeness * 100}%"
2405                )
2406
2407        if not hasattr(_GENERATED_PROFILE, "vehicle_electrical_specification") or not hasattr(
2408            _COMPARISON_PROFILE, "vehicle_electrical_specification"
2409        ):
2410            logger.write_warning_to_html_report("Missing specification information")
2411        else:
2412            comparison = cmp(
2413                _GENERATED_PROFILE.vehicle_electrical_specification,
2414                "==",
2415                _COMPARISON_PROFILE.vehicle_electrical_specification,
2416            )
2417            if (
2418                _GENERATED_PROFILE.vehicle_electrical_specification
2419                != _COMPARISON_PROFILE.vehicle_electrical_specification
2420            ):
2421                logger.write_failure_to_html_report(f"Specification comparison failed: {comparison}")
2422                failed_test = True
2423            else:
2424                logger.write_result_to_html_report(f"Specification comparison passed: {comparison}")
2425
2426        if failed_test:
2427            message = "Comparisons did not match in profiles"
2428            logger.write_failure_to_html_report(message)
2429            pytest.fail(message)
Description Compare if Vehicle Electrical Power #5 values matched
GitHub Issue turnaroundfactor/BMS-HW-Test#393
Instructions 1. Check if Vehicle Electrical Power #5 matched in profiles
Pass / Fail Criteria Pass if Vehicle Electrical Power #5 values match
Estimated Duration 1 second
class TestManufacturerCommands:
2432class TestManufacturerCommands:
2433    """Test Manufacturer Commands are compliant with specs"""
2434
2435    def saft_commands_test(self):
2436        """Tests SAFT Manufacturer Commands"""
2437        with CANBus(BATTERY_CHANNEL) as bus:
2438            manufactured_command_request = CANFrame(
2439                destination_address=_GENERATED_PROFILE.address, pgn=PGN["RQST"], data=[0]
2440            )
2441            invalid_response = []
2442            for address in itertools.chain(
2443                [0xFFD2], range(0xFFD4, 0xFFD9), range(0xFFDC, 0xFFDF), range(0xFFE0, 0xFFE2), [0xFFE4]
2444            ):
2445                manufactured_command_request.data = [address]
2446                default_pgn = PGN[address]
2447                pgn_name = default_pgn.name
2448                logger.write_result_to_html_report(
2449                    f"<span style='font-weight: bold'>PGN {address} ({pgn_name})---</span>"
2450                )
2451
2452                if response_frame := bus.process_call(manufactured_command_request):
2453                    if not response_frame.pgn.id == default_pgn.id:
2454
2455                        if response_frame.pgn.id == PGN["ACKM", [32]].id:
2456                            message = (
2457                                f"Expected {address} ({pgn_name}): Received PGN {response_frame.pgn.id} "
2458                                f"({spn_types.acknowledgement(response_frame.data[0])}) "
2459                            )
2460                            logger.write_warning_to_html_report(message)
2461                        else:
2462                            logger.write_warning_to_html_report(
2463                                f"Expected PGN {address} ({pgn_name}), but received "
2464                                f"{response_frame.pgn.id} ({response_frame.pgn.name}). "
2465                                f"Unable to complete check for command"
2466                            )
2467                        invalid_response.append(f"PGN {address} ({pgn_name})")
2468                        continue
2469                else:
2470                    message = f"Did not receive response from PGN {address} {pgn_name}"
2471                    logger.write_warning_to_html_report(message)
2472                    invalid_response.append(f"PGN {address} ({pgn_name})")
2473
2474                    continue
2475
2476                if response_frame.priority != default_pgn.default_priority:
2477                    message = (
2478                        f"Expected priority level of {default_pgn.default_priority}"
2479                        f" but got priority level {response_frame.priority} for PGN {address}, {pgn_name}"
2480                    )
2481                    invalid_response.append(f"PGN {address} {pgn_name}")
2482                    logger.write_warning_to_html_report(message)
2483
2484                if len(response_frame.packed_data) != 8:
2485                    message = (
2486                        f"Unexpected data length for PGN {address}, {pgn_name}. Expected length of 8, "
2487                        f"received {len(response_frame.packed_data)}"
2488                    )
2489                    logger.write_warning_to_html_report(message)
2490
2491                not_passed_elem = []
2492                for spn, elem in zip(default_pgn.data_field, response_frame.data):
2493                    low_range = 0
2494                    high_range = 3
2495                    spn_name = spn.name
2496                    if spn.name == "Reserved":
2497                        continue
2498
2499                    description = f"({desc})" if (desc := spn.data_type(elem)) else ""
2500
2501                    logger.write_result_to_html_report(f"{spn_name}: {elem}{description}")
2502
2503                    if pgn_name == "Battery ECU Status":
2504                        if spn_name in ("Battery Mode", "FET Array State", "SOC Mode", "Heat Reason"):
2505                            high_range = 7
2506                        elif spn_name == "Long-Term Fault Log Status":
2507                            high_range = 15
2508                        elif spn_name == "Software Part Number":
2509                            high_range = 64255
2510
2511                    if pgn_name in ("Battery Cell Status 1", "Battery Cell Status 2"):
2512                        high_range = 6.4255
2513
2514                    if pgn_name == "Battery Performance":
2515                        if spn_name == "Battery Current":
2516                            low_range = -82000.00
2517                            high_range = 82495.35
2518                        if spn_name == "Internal State of Health":
2519                            low_range = -204.800
2520                            high_range = 204.775
2521
2522                    if pgn_name == "Battery Temperatures":
2523                        if spn_name == "MCU Temperature":
2524                            low_range = -40
2525                            high_range = 210
2526                        else:
2527                            low_range = -50
2528                            high_range = 200
2529
2530                    if pgn_name == "Battery Balancing Circuit Info":
2531                        if spn.name == "Cell Voltage Difference":
2532                            high_range = 6.4255
2533                        if spn_name == "Cell Voltage Sum":
2534                            high_range = 104.8576
2535
2536                    if pgn_name in ("Battery Cell Upper SOC", "Battery Cell Lower SOC"):
2537                        low_range = -10
2538                        high_range = 115
2539
2540                    if pgn_name == "Battery Function Status":
2541                        if spn_name == "Heater Set Point":
2542                            low_range = -50
2543                            high_range = 25
2544                        if spn_name == "Storage Delay Time Limit":
2545                            high_range = 65535
2546                        if spn_name == "Last Storage Duration (Minutes)":
2547                            high_range = 59
2548                        if spn_name == "Last Storage Duration (Hours)":
2549                            high_range = 23
2550                        if spn_name == "Last Storage Duration (Days)":
2551                            high_range = 31
2552                        if spn_name == "Last Storage Duration (Months)":
2553                            high_range = 255
2554                        if spn_name == "Effective Reset Time":
2555                            high_range = 60
2556
2557                    if not low_range <= elem <= high_range:
2558                        logger.write_warning_to_html_report(
2559                            f"{spn.name}: {cmp(elem, '>=', 0, description)} and "
2560                            f"{cmp(elem, '<=', high_range, description)}"
2561                        )
2562                        not_passed_elem.append(spn_name)
2563
2564                if len(not_passed_elem) == 0:
2565                    message = f"✅ All data fields in PGN {default_pgn.id} ({pgn_name}) met requirements"
2566                    logger.write_result_to_html_report(message)
2567
2568            if len(invalid_response) > 0:
2569                message = (
2570                    f"{len(invalid_response)} SAFT Manufacturer Command{'s' if len(invalid_response) > 1 else ''} "
2571                    f"failed: {', '.join(invalid_response)}"
2572                )
2573                logger.write_warning_to_html_report(message)
2574            else:
2575                logger.write_result_to_html_report("All SAFT Manufacturer Commands passed")
2576
2577    @pytest.mark.profile
2578    def test_profile(self) -> None:
2579        """
2580        | Description          | Fingerprint Manufacturer Commands                                |
2581        | :------------------- | :--------------------------------------------------------------- |
2582        | GitHub Issue         | turnaroundfactor/BMS-HW-Test#388                                 |
2583        | Instructions         | 1. Send request for manufacturer command                    </br>\
2584                                 2. Check values in data                                     </br>\
2585                                 3. Log response                                                  |
2586        | Estimated Duration   | 22 seconds                                                       |
2587        """
2588
2589        if _GENERATED_PROFILE.manufacturer_code == ManufacturerID.SAFT:
2590            self.saft_commands_test()
2591        else:
2592            message = "No known manufacturer commands to test"
2593            logger.write_result_to_html_report(message)
2594            pytest.skip(message)

Test Manufacturer Commands are compliant with specs

def saft_commands_test(self):
2435    def saft_commands_test(self):
2436        """Tests SAFT Manufacturer Commands"""
2437        with CANBus(BATTERY_CHANNEL) as bus:
2438            manufactured_command_request = CANFrame(
2439                destination_address=_GENERATED_PROFILE.address, pgn=PGN["RQST"], data=[0]
2440            )
2441            invalid_response = []
2442            for address in itertools.chain(
2443                [0xFFD2], range(0xFFD4, 0xFFD9), range(0xFFDC, 0xFFDF), range(0xFFE0, 0xFFE2), [0xFFE4]
2444            ):
2445                manufactured_command_request.data = [address]
2446                default_pgn = PGN[address]
2447                pgn_name = default_pgn.name
2448                logger.write_result_to_html_report(
2449                    f"<span style='font-weight: bold'>PGN {address} ({pgn_name})---</span>"
2450                )
2451
2452                if response_frame := bus.process_call(manufactured_command_request):
2453                    if not response_frame.pgn.id == default_pgn.id:
2454
2455                        if response_frame.pgn.id == PGN["ACKM", [32]].id:
2456                            message = (
2457                                f"Expected {address} ({pgn_name}): Received PGN {response_frame.pgn.id} "
2458                                f"({spn_types.acknowledgement(response_frame.data[0])}) "
2459                            )
2460                            logger.write_warning_to_html_report(message)
2461                        else:
2462                            logger.write_warning_to_html_report(
2463                                f"Expected PGN {address} ({pgn_name}), but received "
2464                                f"{response_frame.pgn.id} ({response_frame.pgn.name}). "
2465                                f"Unable to complete check for command"
2466                            )
2467                        invalid_response.append(f"PGN {address} ({pgn_name})")
2468                        continue
2469                else:
2470                    message = f"Did not receive response from PGN {address} {pgn_name}"
2471                    logger.write_warning_to_html_report(message)
2472                    invalid_response.append(f"PGN {address} ({pgn_name})")
2473
2474                    continue
2475
2476                if response_frame.priority != default_pgn.default_priority:
2477                    message = (
2478                        f"Expected priority level of {default_pgn.default_priority}"
2479                        f" but got priority level {response_frame.priority} for PGN {address}, {pgn_name}"
2480                    )
2481                    invalid_response.append(f"PGN {address} {pgn_name}")
2482                    logger.write_warning_to_html_report(message)
2483
2484                if len(response_frame.packed_data) != 8:
2485                    message = (
2486                        f"Unexpected data length for PGN {address}, {pgn_name}. Expected length of 8, "
2487                        f"received {len(response_frame.packed_data)}"
2488                    )
2489                    logger.write_warning_to_html_report(message)
2490
2491                not_passed_elem = []
2492                for spn, elem in zip(default_pgn.data_field, response_frame.data):
2493                    low_range = 0
2494                    high_range = 3
2495                    spn_name = spn.name
2496                    if spn.name == "Reserved":
2497                        continue
2498
2499                    description = f"({desc})" if (desc := spn.data_type(elem)) else ""
2500
2501                    logger.write_result_to_html_report(f"{spn_name}: {elem}{description}")
2502
2503                    if pgn_name == "Battery ECU Status":
2504                        if spn_name in ("Battery Mode", "FET Array State", "SOC Mode", "Heat Reason"):
2505                            high_range = 7
2506                        elif spn_name == "Long-Term Fault Log Status":
2507                            high_range = 15
2508                        elif spn_name == "Software Part Number":
2509                            high_range = 64255
2510
2511                    if pgn_name in ("Battery Cell Status 1", "Battery Cell Status 2"):
2512                        high_range = 6.4255
2513
2514                    if pgn_name == "Battery Performance":
2515                        if spn_name == "Battery Current":
2516                            low_range = -82000.00
2517                            high_range = 82495.35
2518                        if spn_name == "Internal State of Health":
2519                            low_range = -204.800
2520                            high_range = 204.775
2521
2522                    if pgn_name == "Battery Temperatures":
2523                        if spn_name == "MCU Temperature":
2524                            low_range = -40
2525                            high_range = 210
2526                        else:
2527                            low_range = -50
2528                            high_range = 200
2529
2530                    if pgn_name == "Battery Balancing Circuit Info":
2531                        if spn.name == "Cell Voltage Difference":
2532                            high_range = 6.4255
2533                        if spn_name == "Cell Voltage Sum":
2534                            high_range = 104.8576
2535
2536                    if pgn_name in ("Battery Cell Upper SOC", "Battery Cell Lower SOC"):
2537                        low_range = -10
2538                        high_range = 115
2539
2540                    if pgn_name == "Battery Function Status":
2541                        if spn_name == "Heater Set Point":
2542                            low_range = -50
2543                            high_range = 25
2544                        if spn_name == "Storage Delay Time Limit":
2545                            high_range = 65535
2546                        if spn_name == "Last Storage Duration (Minutes)":
2547                            high_range = 59
2548                        if spn_name == "Last Storage Duration (Hours)":
2549                            high_range = 23
2550                        if spn_name == "Last Storage Duration (Days)":
2551                            high_range = 31
2552                        if spn_name == "Last Storage Duration (Months)":
2553                            high_range = 255
2554                        if spn_name == "Effective Reset Time":
2555                            high_range = 60
2556
2557                    if not low_range <= elem <= high_range:
2558                        logger.write_warning_to_html_report(
2559                            f"{spn.name}: {cmp(elem, '>=', 0, description)} and "
2560                            f"{cmp(elem, '<=', high_range, description)}"
2561                        )
2562                        not_passed_elem.append(spn_name)
2563
2564                if len(not_passed_elem) == 0:
2565                    message = f"✅ All data fields in PGN {default_pgn.id} ({pgn_name}) met requirements"
2566                    logger.write_result_to_html_report(message)
2567
2568            if len(invalid_response) > 0:
2569                message = (
2570                    f"{len(invalid_response)} SAFT Manufacturer Command{'s' if len(invalid_response) > 1 else ''} "
2571                    f"failed: {', '.join(invalid_response)}"
2572                )
2573                logger.write_warning_to_html_report(message)
2574            else:
2575                logger.write_result_to_html_report("All SAFT Manufacturer Commands passed")

Tests SAFT Manufacturer Commands

@pytest.mark.profile
def test_profile(self) -> None:
2577    @pytest.mark.profile
2578    def test_profile(self) -> None:
2579        """
2580        | Description          | Fingerprint Manufacturer Commands                                |
2581        | :------------------- | :--------------------------------------------------------------- |
2582        | GitHub Issue         | turnaroundfactor/BMS-HW-Test#388                                 |
2583        | Instructions         | 1. Send request for manufacturer command                    </br>\
2584                                 2. Check values in data                                     </br>\
2585                                 3. Log response                                                  |
2586        | Estimated Duration   | 22 seconds                                                       |
2587        """
2588
2589        if _GENERATED_PROFILE.manufacturer_code == ManufacturerID.SAFT:
2590            self.saft_commands_test()
2591        else:
2592            message = "No known manufacturer commands to test"
2593            logger.write_result_to_html_report(message)
2594            pytest.skip(message)
Description Fingerprint Manufacturer Commands
GitHub Issue turnaroundfactor/BMS-HW-Test#388
Instructions 1. Send request for manufacturer command
2. Check values in data
3. Log response
Estimated Duration 22 seconds
class TestECUInformation:
2720class TestECUInformation:
2721    """Gets information about the physical ECU and its hardware"""
2722
2723    @staticmethod
2724    def bytes_to_ascii(bs: list[float]) -> list[str]:
2725        """Converts bytes to ASCII string"""
2726        s: str = ""
2727        for b in bs:
2728            h = re.sub(r"^[^0-9a-fA-F]+$", "", f"{b:x}")
2729            try:
2730                ba = bytearray.fromhex(h)[::-1]
2731                s += ba.decode("utf-8", "ignore")
2732                s = re.sub(r"[^\x20-\x7E]", "", s)
2733            except ValueError:
2734                # NOTE: This will ignore any invalid packets (from BrenTronics)
2735                logger.write_warning_to_report(f"Skipping invalid hex: {b:x}")
2736        return list(filter(None, s.split("*")))
2737
2738    @pytest.mark.profile
2739    def test_ecu_information(self) -> None:
2740        """
2741        | Description          | Get information from ECUID response                              |
2742        | :------------------- | :--------------------------------------------------------------- |
2743        | GitHub Issue         | turnaroundfactor/BMS-HW-Test#395                                 |
2744        | Instructions         | 1. Request ECUID data                                       </br>\
2745                                 2. Log returned values                                           |
2746        | Estimated Duration   | 30 seconds                                                       |
2747        """
2748
2749        info = []
2750        with CANBus(BATTERY_CHANNEL) as bus:
2751            ecu_request = CANFrame(pgn=PGN["Request"], data=[PGN["ECUID"].id])
2752            if tp_cm_frame := bus.process_call(ecu_request):
2753                if tp_cm_frame is not None:
2754                    if tp_cm_frame.pgn.id == PGN["TP.CM", [32]].id:
2755                        data = []
2756                        cts_request = CANFrame(pgn=PGN["TP.CM"], data=[17, 0, 1, 0xFF, 0])
2757                        if data_frame := bus.process_call(cts_request):
2758                            if data_frame is not None:
2759                                if data_frame.pgn.id == PGN["TP.DT"].id:
2760                                    packet_count = int(tp_cm_frame.data[2])
2761                                    # NOTE: This will ignore the invalid header packet from BrenTronics
2762                                    if _GENERATED_PROFILE.manufacturer_code != ManufacturerID.BRENTRONICS:
2763                                        data.append(data_frame.data[1])
2764                                        packet_count -= 1
2765                                    for _ in range(packet_count):
2766                                        data_message = bus.read_input()
2767                                        if data_message is not None:
2768                                            frame = CANFrame.decode(data_message.arbitration_id, data_message.data)
2769                                            if frame.pgn.id == PGN["TP.DT"].id:
2770                                                data.append(frame.data[1])
2771                                            else:
2772                                                Errors.unexpected_packet("TP.DT", frame)
2773                                                break
2774                                        else:
2775                                            Errors.no_packet("TP.DT")
2776                                            break
2777                                    info = self.bytes_to_ascii(data)
2778                                    eom_request = CANFrame(
2779                                        pgn=PGN["TP.CM"],
2780                                        data=[17, tp_cm_frame.data[1], packet_count, 0xFF, tp_cm_frame.data[-1]],
2781                                    )
2782                                    if eom_frame := bus.process_call(eom_request):
2783                                        if eom_frame is not None:
2784                                            if eom_frame.pgn.id == PGN["DM15"].id:
2785                                                if eom_frame.data[2] == 4:  # Operation Completed
2786                                                    logger.write_info_to_report("ECUID data transfer successful")
2787                                                else:
2788                                                    logger.write_warning_to_html_report("Unsuccessful EOM response")
2789                                            else:
2790                                                Errors.unexpected_packet("DM15", eom_frame)
2791                                        else:
2792                                            Errors.no_packet("DM15")
2793                                    else:
2794                                        # timeout
2795                                        logger.write_warning_to_report("No response after sending EOM (DM15)")
2796                                else:
2797                                    Errors.unexpected_packet("TP.DT", data_frame)
2798                            else:
2799                                Errors.no_packet("TP.DT")
2800                        else:
2801                            message = f"Did not receive response from PGN {PGN['TP.CM', [32]].id}"
2802                            logger.write_warning_to_html_report(message)
2803                            _GENERATED_PROFILE.ecu = BadStates.NO_RESPONSE.name
2804                            _GENERATED_PROFILE.ecu_specification = "invalid"
2805                    else:
2806                        Errors.unexpected_packet("TP.CM", tp_cm_frame)
2807                else:
2808                    Errors.no_packet("TP.CM")
2809            else:
2810                message = f"Did not receive response from PGN {PGN['ECUID', [32]].id}"
2811                logger.write_warning_to_html_report(message)
2812                _GENERATED_PROFILE.ecu = BadStates.NO_RESPONSE.name
2813                _GENERATED_PROFILE.ecu_specification = "invalid"
2814
2815            if len(info) > 0:
2816                _GENERATED_PROFILE.ecu = {
2817                    "part_number": info[0],
2818                    "serial_number": info[1],
2819                    "location_name": info[2],
2820                    "manufacturer": info[3],
2821                    "classification": info[4],
2822                }
2823                logger.write_result_to_html_report("<span style='font-weight: bold'>ECUID Information </span>")
2824                for key, value in _GENERATED_PROFILE.ecu.items():
2825                    logger.write_result_to_html_report(f"{key.strip().replace('_', ' ').title()}: {value}")
2826                _GENERATED_PROFILE.ecu_specification = "valid"
2827
2828            else:
2829                _GENERATED_PROFILE.ecu = BadStates.INVALID_RESPONSE.name
2830                logger.write_warning_to_html_report("Could not get ECU information")
2831                _GENERATED_PROFILE.ecu_specification = "invalid"
2832
2833    def test_comparison(self) -> None:
2834        """
2835        | Description          | Compare ECU Information                                          |
2836        | :------------------- | :--------------------------------------------------------------- |
2837        | GitHub Issue         | turnaroundfactor/BMS-HW-Test#395                                 |
2838        | Instructions         | 1. Confirm identity numbers are unique for each battery          |
2839        | Pass / Fail Criteria | Pass if ECU values match / serial number is unique               |
2840        | Estimated Duration   | 1 second                                                         |
2841        """
2842
2843        failed_test = False
2844        if not hasattr(_GENERATED_PROFILE, "ecu") or not hasattr(_COMPARISON_PROFILE, "ecu"):
2845            logger.write_result_to_html_report("Nothing to compare. The profile test may have failed or been skipped")
2846            pytest.skip("Nothing to compare. The profile test may have failed or been skipped")
2847
2848        if isinstance(_GENERATED_PROFILE.ecu, str) or isinstance(_COMPARISON_PROFILE.ecu, str):
2849            if not isinstance(_GENERATED_PROFILE.ecu, type(_COMPARISON_PROFILE.ecu)):
2850                message = "Unable to compare profiles, ECU types did not match"
2851                logger.write_failure_to_html_report(message)
2852                pytest.fail(message)
2853
2854            comparison = cmp(_GENERATED_PROFILE.ecu, "==", _COMPARISON_PROFILE.ecu)
2855
2856            if _GENERATED_PROFILE.ecu != _COMPARISON_PROFILE.ecu:
2857                message = f"ECU did not match: {comparison}"
2858                logger.write_failure_to_html_report(message)
2859                pytest.fail(message)
2860
2861            message = f"ECU matched: {comparison}"
2862            logger.write_result_to_html_report(message)
2863        else:
2864
2865            failed_categories = []
2866
2867            for key, value in _GENERATED_PROFILE.ecu.items():
2868                key_text = key.replace("_", " ").title()
2869
2870                comparison = cmp(value, "==", _COMPARISON_PROFILE.ecu[key])
2871
2872                if key == "serial_number":
2873                    if value == _COMPARISON_PROFILE.ecu[key]:
2874                        message = f"{key_text} matched: {comparison}"
2875                        logger.write_warning_to_html_report(message)
2876                    else:
2877                        message = f"{key_text}: {comparison}"
2878                        logger.write_result_to_html_report(message)
2879
2880                elif value != _COMPARISON_PROFILE.ecu[key]:
2881                    message = f"{key_text} did not match: {comparison}"
2882                    logger.write_warning_to_html_report(message)
2883                    failed_categories.append(key_text)
2884                else:
2885                    message = f"{key_text}: {comparison}"
2886                    logger.write_result_to_html_report(message)
2887
2888            fail_length = len(failed_categories)
2889
2890            if fail_length > 0:
2891                categories = "data values" if fail_length > 1 else "data value"
2892                message = f"{fail_length} {categories} failed profile comparisons: {', '.join(failed_categories)}"
2893                logger.write_failure_to_html_report(message)
2894                failed_test = True
2895
2896        if not hasattr(_GENERATED_PROFILE, "ecu_specification") or not hasattr(
2897            _COMPARISON_PROFILE, "ecu_specification"
2898        ):
2899            logger.write_warning_to_html_report("Missing specification information")
2900        else:
2901            comparison = cmp(
2902                _GENERATED_PROFILE.ecu_specification,
2903                "==",
2904                _COMPARISON_PROFILE.ecu_specification,
2905            )
2906            if _GENERATED_PROFILE.ecu_specification != _COMPARISON_PROFILE.ecu_specification:
2907                logger.write_failure_to_html_report(f"Specification comparison failed: {comparison}")
2908                failed_test = True
2909            else:
2910                logger.write_result_to_html_report(f"Specification comparison passed: {comparison}")
2911
2912        if failed_test:
2913            message = "Comparisons did not meet expectations"
2914            logger.write_failure_to_html_report(message)
2915            pytest.fail(message)
2916        else:
2917            logger.write_result_to_html_report("ECUID profile comparisons met requirements")

Gets information about the physical ECU and its hardware

@staticmethod
def bytes_to_ascii(bs: list[float]) -> list[str]:
2723    @staticmethod
2724    def bytes_to_ascii(bs: list[float]) -> list[str]:
2725        """Converts bytes to ASCII string"""
2726        s: str = ""
2727        for b in bs:
2728            h = re.sub(r"^[^0-9a-fA-F]+$", "", f"{b:x}")
2729            try:
2730                ba = bytearray.fromhex(h)[::-1]
2731                s += ba.decode("utf-8", "ignore")
2732                s = re.sub(r"[^\x20-\x7E]", "", s)
2733            except ValueError:
2734                # NOTE: This will ignore any invalid packets (from BrenTronics)
2735                logger.write_warning_to_report(f"Skipping invalid hex: {b:x}")
2736        return list(filter(None, s.split("*")))

Converts bytes to ASCII string

@pytest.mark.profile
def test_ecu_information(self) -> None:
2738    @pytest.mark.profile
2739    def test_ecu_information(self) -> None:
2740        """
2741        | Description          | Get information from ECUID response                              |
2742        | :------------------- | :--------------------------------------------------------------- |
2743        | GitHub Issue         | turnaroundfactor/BMS-HW-Test#395                                 |
2744        | Instructions         | 1. Request ECUID data                                       </br>\
2745                                 2. Log returned values                                           |
2746        | Estimated Duration   | 30 seconds                                                       |
2747        """
2748
2749        info = []
2750        with CANBus(BATTERY_CHANNEL) as bus:
2751            ecu_request = CANFrame(pgn=PGN["Request"], data=[PGN["ECUID"].id])
2752            if tp_cm_frame := bus.process_call(ecu_request):
2753                if tp_cm_frame is not None:
2754                    if tp_cm_frame.pgn.id == PGN["TP.CM", [32]].id:
2755                        data = []
2756                        cts_request = CANFrame(pgn=PGN["TP.CM"], data=[17, 0, 1, 0xFF, 0])
2757                        if data_frame := bus.process_call(cts_request):
2758                            if data_frame is not None:
2759                                if data_frame.pgn.id == PGN["TP.DT"].id:
2760                                    packet_count = int(tp_cm_frame.data[2])
2761                                    # NOTE: This will ignore the invalid header packet from BrenTronics
2762                                    if _GENERATED_PROFILE.manufacturer_code != ManufacturerID.BRENTRONICS:
2763                                        data.append(data_frame.data[1])
2764                                        packet_count -= 1
2765                                    for _ in range(packet_count):
2766                                        data_message = bus.read_input()
2767                                        if data_message is not None:
2768                                            frame = CANFrame.decode(data_message.arbitration_id, data_message.data)
2769                                            if frame.pgn.id == PGN["TP.DT"].id:
2770                                                data.append(frame.data[1])
2771                                            else:
2772                                                Errors.unexpected_packet("TP.DT", frame)
2773                                                break
2774                                        else:
2775                                            Errors.no_packet("TP.DT")
2776                                            break
2777                                    info = self.bytes_to_ascii(data)
2778                                    eom_request = CANFrame(
2779                                        pgn=PGN["TP.CM"],
2780                                        data=[17, tp_cm_frame.data[1], packet_count, 0xFF, tp_cm_frame.data[-1]],
2781                                    )
2782                                    if eom_frame := bus.process_call(eom_request):
2783                                        if eom_frame is not None:
2784                                            if eom_frame.pgn.id == PGN["DM15"].id:
2785                                                if eom_frame.data[2] == 4:  # Operation Completed
2786                                                    logger.write_info_to_report("ECUID data transfer successful")
2787                                                else:
2788                                                    logger.write_warning_to_html_report("Unsuccessful EOM response")
2789                                            else:
2790                                                Errors.unexpected_packet("DM15", eom_frame)
2791                                        else:
2792                                            Errors.no_packet("DM15")
2793                                    else:
2794                                        # timeout
2795                                        logger.write_warning_to_report("No response after sending EOM (DM15)")
2796                                else:
2797                                    Errors.unexpected_packet("TP.DT", data_frame)
2798                            else:
2799                                Errors.no_packet("TP.DT")
2800                        else:
2801                            message = f"Did not receive response from PGN {PGN['TP.CM', [32]].id}"
2802                            logger.write_warning_to_html_report(message)
2803                            _GENERATED_PROFILE.ecu = BadStates.NO_RESPONSE.name
2804                            _GENERATED_PROFILE.ecu_specification = "invalid"
2805                    else:
2806                        Errors.unexpected_packet("TP.CM", tp_cm_frame)
2807                else:
2808                    Errors.no_packet("TP.CM")
2809            else:
2810                message = f"Did not receive response from PGN {PGN['ECUID', [32]].id}"
2811                logger.write_warning_to_html_report(message)
2812                _GENERATED_PROFILE.ecu = BadStates.NO_RESPONSE.name
2813                _GENERATED_PROFILE.ecu_specification = "invalid"
2814
2815            if len(info) > 0:
2816                _GENERATED_PROFILE.ecu = {
2817                    "part_number": info[0],
2818                    "serial_number": info[1],
2819                    "location_name": info[2],
2820                    "manufacturer": info[3],
2821                    "classification": info[4],
2822                }
2823                logger.write_result_to_html_report("<span style='font-weight: bold'>ECUID Information </span>")
2824                for key, value in _GENERATED_PROFILE.ecu.items():
2825                    logger.write_result_to_html_report(f"{key.strip().replace('_', ' ').title()}: {value}")
2826                _GENERATED_PROFILE.ecu_specification = "valid"
2827
2828            else:
2829                _GENERATED_PROFILE.ecu = BadStates.INVALID_RESPONSE.name
2830                logger.write_warning_to_html_report("Could not get ECU information")
2831                _GENERATED_PROFILE.ecu_specification = "invalid"
Description Get information from ECUID response
GitHub Issue turnaroundfactor/BMS-HW-Test#395
Instructions 1. Request ECUID data
2. Log returned values
Estimated Duration 30 seconds
def test_comparison(self) -> None:
2833    def test_comparison(self) -> None:
2834        """
2835        | Description          | Compare ECU Information                                          |
2836        | :------------------- | :--------------------------------------------------------------- |
2837        | GitHub Issue         | turnaroundfactor/BMS-HW-Test#395                                 |
2838        | Instructions         | 1. Confirm identity numbers are unique for each battery          |
2839        | Pass / Fail Criteria | Pass if ECU values match / serial number is unique               |
2840        | Estimated Duration   | 1 second                                                         |
2841        """
2842
2843        failed_test = False
2844        if not hasattr(_GENERATED_PROFILE, "ecu") or not hasattr(_COMPARISON_PROFILE, "ecu"):
2845            logger.write_result_to_html_report("Nothing to compare. The profile test may have failed or been skipped")
2846            pytest.skip("Nothing to compare. The profile test may have failed or been skipped")
2847
2848        if isinstance(_GENERATED_PROFILE.ecu, str) or isinstance(_COMPARISON_PROFILE.ecu, str):
2849            if not isinstance(_GENERATED_PROFILE.ecu, type(_COMPARISON_PROFILE.ecu)):
2850                message = "Unable to compare profiles, ECU types did not match"
2851                logger.write_failure_to_html_report(message)
2852                pytest.fail(message)
2853
2854            comparison = cmp(_GENERATED_PROFILE.ecu, "==", _COMPARISON_PROFILE.ecu)
2855
2856            if _GENERATED_PROFILE.ecu != _COMPARISON_PROFILE.ecu:
2857                message = f"ECU did not match: {comparison}"
2858                logger.write_failure_to_html_report(message)
2859                pytest.fail(message)
2860
2861            message = f"ECU matched: {comparison}"
2862            logger.write_result_to_html_report(message)
2863        else:
2864
2865            failed_categories = []
2866
2867            for key, value in _GENERATED_PROFILE.ecu.items():
2868                key_text = key.replace("_", " ").title()
2869
2870                comparison = cmp(value, "==", _COMPARISON_PROFILE.ecu[key])
2871
2872                if key == "serial_number":
2873                    if value == _COMPARISON_PROFILE.ecu[key]:
2874                        message = f"{key_text} matched: {comparison}"
2875                        logger.write_warning_to_html_report(message)
2876                    else:
2877                        message = f"{key_text}: {comparison}"
2878                        logger.write_result_to_html_report(message)
2879
2880                elif value != _COMPARISON_PROFILE.ecu[key]:
2881                    message = f"{key_text} did not match: {comparison}"
2882                    logger.write_warning_to_html_report(message)
2883                    failed_categories.append(key_text)
2884                else:
2885                    message = f"{key_text}: {comparison}"
2886                    logger.write_result_to_html_report(message)
2887
2888            fail_length = len(failed_categories)
2889
2890            if fail_length > 0:
2891                categories = "data values" if fail_length > 1 else "data value"
2892                message = f"{fail_length} {categories} failed profile comparisons: {', '.join(failed_categories)}"
2893                logger.write_failure_to_html_report(message)
2894                failed_test = True
2895
2896        if not hasattr(_GENERATED_PROFILE, "ecu_specification") or not hasattr(
2897            _COMPARISON_PROFILE, "ecu_specification"
2898        ):
2899            logger.write_warning_to_html_report("Missing specification information")
2900        else:
2901            comparison = cmp(
2902                _GENERATED_PROFILE.ecu_specification,
2903                "==",
2904                _COMPARISON_PROFILE.ecu_specification,
2905            )
2906            if _GENERATED_PROFILE.ecu_specification != _COMPARISON_PROFILE.ecu_specification:
2907                logger.write_failure_to_html_report(f"Specification comparison failed: {comparison}")
2908                failed_test = True
2909            else:
2910                logger.write_result_to_html_report(f"Specification comparison passed: {comparison}")
2911
2912        if failed_test:
2913            message = "Comparisons did not meet expectations"
2914            logger.write_failure_to_html_report(message)
2915            pytest.fail(message)
2916        else:
2917            logger.write_result_to_html_report("ECUID profile comparisons met requirements")
Description Compare ECU Information
GitHub Issue turnaroundfactor/BMS-HW-Test#395
Instructions 1. Confirm identity numbers are unique for each battery
Pass / Fail Criteria Pass if ECU values match / serial number is unique
Estimated Duration 1 second
class TestAnalyzeTimingCommands:
2920class TestAnalyzeTimingCommands:
2921    """Test Supported Commands & Conduct Timing Analysis"""
2922
2923    @pytest.mark.profile
2924    def test_analyze_timing_commands(self) -> None:
2925        """
2926        | Description          | Analyze timing of commands                                       |
2927        | :------------------- | :--------------------------------------------------------------- |
2928        | GitHub Issue         | turnaroundfactor/BMS-HW-Test#383                                 |
2929        | Instructions         | 1. Make requests for supported commands                     </br>\
2930                                 2. Conduct timing analysis                                  </br>\
2931                                 3. Log average times                                             |
2932        | Estimated Duration   | 35 seconds                                                       |
2933        """
2934
2935        with CANBus(BATTERY_CHANNEL) as bus:
2936            j1939_transmit_commands = [0xEE00, 0xFEE6, 0xFCB6, 0xFECA, 0xFECB, 0xFE50, 0xFDC5, 0xFEDA, 0xD800, 0xE800]
2937            mil_prf_commands = [0xFF00, 0xFF01, 0xFF02, 0xFF03, 0xFF04, 0xFF05, 0xFF06, 0xFF07, 0xFF08]
2938            if hasattr(_GENERATED_PROFILE, "proprietary_commands"):
2939                proprietary_commands = list(_GENERATED_PROFILE.proprietary_commands.keys())
2940            else:
2941                proprietary_commands = []
2942
2943            # Remove potential duplicate commands
2944            commands_list = list(dict.fromkeys(j1939_transmit_commands + mil_prf_commands + proprietary_commands))
2945
2946            times_list: dict[int | Any, list[Any]] = {}
2947            average_times = {}
2948            no_valid_response: dict[int, bool] = {}
2949            for _ in range(0, 3):
2950                for command in commands_list:
2951                    command_name = PGN[command].name
2952                    if no_valid_response.get(command):
2953                        continue
2954
2955                    request_frame = CANFrame(
2956                        destination_address=_GENERATED_PROFILE.address, pgn=PGN["RQST"], data=[command]
2957                    )
2958                    start_time = time.perf_counter()
2959                    if response := bus.process_call(request_frame):
2960                        if response.pgn.id == PGN["TP.CM", [32]].id:
2961                            expected_bytes = int(response.data[1])
2962                            expected_packets = 0
2963                            if _GENERATED_PROFILE.manufacturer_code == ManufacturerID.BRENTRONICS:
2964                                expected_packets = int(response.data[2] + 1)
2965                            else:
2966                                expected_packets = int(response.data[2])
2967
2968                            rts_pgn_id = int(response.data[-1])
2969                            if command in (PGN["ECUID", [32]].id, PGN["SOFT", [32]].id):
2970                                cts_request = CANFrame(pgn=PGN["TP.CM"], data=[17, 0, 1, 0xFF, 0])
2971                            else:
2972                                cts_request = CANFrame(pgn=PGN["TP.CM"], data=[19, 0, 0, 0xFF, 0])
2973                            cts_request.data[1] = expected_packets
2974                            cts_request.data[-1] = rts_pgn_id
2975
2976                            bus.send_message(cts_request.message())
2977                            for i in range(expected_packets):
2978                                data_frame = bus.read_frame()
2979                                if data_frame is None:
2980                                    message = (
2981                                        f"Received no data packet when in TP.CM protocol for command"
2982                                        f" {command}, {command_name}"
2983                                    )
2984                                    logger.write_warning_to_html_report(message)
2985                                if data_frame.pgn.id != PGN["TP.DT", [32]].id:
2986                                    message = (
2987                                        f"Expected data frame {i + 1}, got PGN {data_frame.pgn.id} "
2988                                        f"for PGN request {command} ({command_name}) instead"
2989                                    )
2990                                    logger.write_warning_to_html_report(message)
2991
2992                            # Send acknowledgement frame
2993                            end_time = time.perf_counter()
2994                            end_acknowledge_frame = CANFrame(pgn=PGN["TP.CM"], data=[19, 0, 0, 0xFF, 0])
2995                            end_acknowledge_frame.data[1] = expected_bytes
2996                            end_acknowledge_frame.data[2] = expected_packets
2997                            end_acknowledge_frame.data[-1] = rts_pgn_id
2998                            bus.send_message(end_acknowledge_frame.message())
2999                        else:
3000                            end_time = time.perf_counter()
3001
3002                        if response.pgn.id in (command, PGN["TP.CM", [32]].id):
3003                            if not times_list.get(command):
3004                                times_list[command] = [end_time - start_time]
3005                            else:
3006                                times_list[command].append(end_time - start_time)
3007                        else:
3008                            if response.pgn.id == PGN["ACKM", [32]].id:
3009                                message = (
3010                                    f"PGN {command} ({command_name}): Received PGN {response.pgn.id} "
3011                                    f"({spn_types.acknowledgement(response.data[0])}) "
3012                                )
3013                                average_times[command] = (
3014                                    f"PGN {response.pgn.id} ({spn_types.acknowledgement(response.data[0])})"
3015                                )
3016                            else:
3017                                message = (
3018                                    f"PGN {command} ({command_name}): Received PGN {response.pgn.id} "
3019                                    f"({response.pgn.name})."
3020                                )
3021                                average_times[command] = f"PGN {response.pgn.id} ({response.pgn.name})"
3022                            logger.write_warning_to_html_report(message)
3023                            no_valid_response[command] = True
3024                            continue
3025                    else:
3026                        logger.write_warning_to_html_report(
3027                            f"Did not receive any response for PGN {command} ({command_name})"
3028                        )
3029                        no_valid_response[command] = True
3030                        average_times[command] = "No response"
3031                        continue
3032
3033            for pgn, item in times_list.items():
3034                times = item
3035                average = sum(times) / len(times)
3036                average_times[pgn] = average
3037                message = f"PGN {pgn} ({PGN[pgn].name}) average time: {average:.6f} seconds"
3038                logger.write_result_to_html_report(message)
3039
3040            _GENERATED_PROFILE.average_times = average_times
3041
3042    def test_comparison(self) -> None:
3043        """
3044        | Description          | Compare average time values                                      |
3045        | :------------------- | :--------------------------------------------------------------- |
3046        | GitHub Issue         | turnaroundfactor/BMS-HW-Test#393                                 |
3047        | Instructions         | 1. Check if profiles complete commands in approx. same time      |
3048        | Pass / Fail Criteria | Pass if profiles time completion are closely matched             |
3049        | Estimated Duration   | 1 second                                                         |
3050        """
3051
3052        if not hasattr(_GENERATED_PROFILE, "average_times") or not hasattr(_COMPARISON_PROFILE, "average_times"):
3053            message = "Nothing to compare. The profile test may have failed or been skipped"
3054            logger.write_failure_to_html_report(message)
3055            pytest.fail(message)
3056
3057        generated_averages = _GENERATED_PROFILE.average_times
3058        comparison_averages = _COMPARISON_PROFILE.average_times
3059
3060        averages_not_close = []
3061        missing_commands = []
3062        percent_closeness = 0.50
3063        for pgn in generated_averages:
3064            if not comparison_averages.get(str(pgn)):
3065                logger.write_warning_to_html_report(f"Missing PGN {pgn} ({PGN[pgn].name}) from comparison profile")
3066                missing_commands.append(pgn)
3067                continue
3068
3069            if isinstance(generated_averages[pgn], str) or isinstance(comparison_averages[str(pgn)], str):
3070                if generated_averages[pgn] != comparison_averages[str(pgn)]:
3071                    logger.write_warning_to_html_report(
3072                        f"PGN {pgn} ({PGN[pgn].name}): {generated_averages[pgn]}{comparison_averages[str(pgn)]}"
3073                    )
3074                else:
3075                    logger.write_result_to_html_report(
3076                        f"PGN {pgn} ({PGN[pgn].name}): {generated_averages[pgn]} = {comparison_averages[str(pgn)]}"
3077                    )
3078                continue
3079
3080            diff = abs(comparison_averages[str(pgn)] - generated_averages[pgn])
3081            average = (generated_averages[pgn] + comparison_averages[str(pgn)]) / 2
3082            percentage = round((diff / average), 4)
3083            comparison = cmp(
3084                percentage * 100,
3085                "<=",
3086                percent_closeness * 100,
3087                "%",
3088            )
3089
3090            if not math.isclose(
3091                generated_averages[pgn],
3092                comparison_averages[str(pgn)],
3093                rel_tol=percent_closeness,
3094            ):
3095                logger.write_warning_to_html_report(f"PGN {pgn} ({PGN[pgn].name}) average percentage: {comparison}")
3096                averages_not_close.append(pgn)
3097            else:
3098                logger.write_result_to_html_report(f"PGN {pgn} ({PGN[pgn].name}) average percentage: {comparison}")
3099
3100        length_not_close = len(averages_not_close)
3101        if length_not_close > 0:
3102            logger.write_failure_to_html_report(
3103                f"{length_not_close} comparison{'s' if length_not_close > 1 else ''} "
3104                f"did not meet expectations. See warnings above for more details."
3105            )
3106            pytest.fail("Some comparison times were not within range")
3107
3108        if len(missing_commands) > 0:
3109            logger.write_failure_to_html_report(f"{len(missing_commands)} PGNs were missing from comparison profile")
3110
3111        logger.write_result_to_html_report(
3112            f"All PGN Average times for profiles were within expected range of {percent_closeness * 100}%"
3113        )

Test Supported Commands & Conduct Timing Analysis

@pytest.mark.profile
def test_analyze_timing_commands(self) -> None:
2923    @pytest.mark.profile
2924    def test_analyze_timing_commands(self) -> None:
2925        """
2926        | Description          | Analyze timing of commands                                       |
2927        | :------------------- | :--------------------------------------------------------------- |
2928        | GitHub Issue         | turnaroundfactor/BMS-HW-Test#383                                 |
2929        | Instructions         | 1. Make requests for supported commands                     </br>\
2930                                 2. Conduct timing analysis                                  </br>\
2931                                 3. Log average times                                             |
2932        | Estimated Duration   | 35 seconds                                                       |
2933        """
2934
2935        with CANBus(BATTERY_CHANNEL) as bus:
2936            j1939_transmit_commands = [0xEE00, 0xFEE6, 0xFCB6, 0xFECA, 0xFECB, 0xFE50, 0xFDC5, 0xFEDA, 0xD800, 0xE800]
2937            mil_prf_commands = [0xFF00, 0xFF01, 0xFF02, 0xFF03, 0xFF04, 0xFF05, 0xFF06, 0xFF07, 0xFF08]
2938            if hasattr(_GENERATED_PROFILE, "proprietary_commands"):
2939                proprietary_commands = list(_GENERATED_PROFILE.proprietary_commands.keys())
2940            else:
2941                proprietary_commands = []
2942
2943            # Remove potential duplicate commands
2944            commands_list = list(dict.fromkeys(j1939_transmit_commands + mil_prf_commands + proprietary_commands))
2945
2946            times_list: dict[int | Any, list[Any]] = {}
2947            average_times = {}
2948            no_valid_response: dict[int, bool] = {}
2949            for _ in range(0, 3):
2950                for command in commands_list:
2951                    command_name = PGN[command].name
2952                    if no_valid_response.get(command):
2953                        continue
2954
2955                    request_frame = CANFrame(
2956                        destination_address=_GENERATED_PROFILE.address, pgn=PGN["RQST"], data=[command]
2957                    )
2958                    start_time = time.perf_counter()
2959                    if response := bus.process_call(request_frame):
2960                        if response.pgn.id == PGN["TP.CM", [32]].id:
2961                            expected_bytes = int(response.data[1])
2962                            expected_packets = 0
2963                            if _GENERATED_PROFILE.manufacturer_code == ManufacturerID.BRENTRONICS:
2964                                expected_packets = int(response.data[2] + 1)
2965                            else:
2966                                expected_packets = int(response.data[2])
2967
2968                            rts_pgn_id = int(response.data[-1])
2969                            if command in (PGN["ECUID", [32]].id, PGN["SOFT", [32]].id):
2970                                cts_request = CANFrame(pgn=PGN["TP.CM"], data=[17, 0, 1, 0xFF, 0])
2971                            else:
2972                                cts_request = CANFrame(pgn=PGN["TP.CM"], data=[19, 0, 0, 0xFF, 0])
2973                            cts_request.data[1] = expected_packets
2974                            cts_request.data[-1] = rts_pgn_id
2975
2976                            bus.send_message(cts_request.message())
2977                            for i in range(expected_packets):
2978                                data_frame = bus.read_frame()
2979                                if data_frame is None:
2980                                    message = (
2981                                        f"Received no data packet when in TP.CM protocol for command"
2982                                        f" {command}, {command_name}"
2983                                    )
2984                                    logger.write_warning_to_html_report(message)
2985                                if data_frame.pgn.id != PGN["TP.DT", [32]].id:
2986                                    message = (
2987                                        f"Expected data frame {i + 1}, got PGN {data_frame.pgn.id} "
2988                                        f"for PGN request {command} ({command_name}) instead"
2989                                    )
2990                                    logger.write_warning_to_html_report(message)
2991
2992                            # Send acknowledgement frame
2993                            end_time = time.perf_counter()
2994                            end_acknowledge_frame = CANFrame(pgn=PGN["TP.CM"], data=[19, 0, 0, 0xFF, 0])
2995                            end_acknowledge_frame.data[1] = expected_bytes
2996                            end_acknowledge_frame.data[2] = expected_packets
2997                            end_acknowledge_frame.data[-1] = rts_pgn_id
2998                            bus.send_message(end_acknowledge_frame.message())
2999                        else:
3000                            end_time = time.perf_counter()
3001
3002                        if response.pgn.id in (command, PGN["TP.CM", [32]].id):
3003                            if not times_list.get(command):
3004                                times_list[command] = [end_time - start_time]
3005                            else:
3006                                times_list[command].append(end_time - start_time)
3007                        else:
3008                            if response.pgn.id == PGN["ACKM", [32]].id:
3009                                message = (
3010                                    f"PGN {command} ({command_name}): Received PGN {response.pgn.id} "
3011                                    f"({spn_types.acknowledgement(response.data[0])}) "
3012                                )
3013                                average_times[command] = (
3014                                    f"PGN {response.pgn.id} ({spn_types.acknowledgement(response.data[0])})"
3015                                )
3016                            else:
3017                                message = (
3018                                    f"PGN {command} ({command_name}): Received PGN {response.pgn.id} "
3019                                    f"({response.pgn.name})."
3020                                )
3021                                average_times[command] = f"PGN {response.pgn.id} ({response.pgn.name})"
3022                            logger.write_warning_to_html_report(message)
3023                            no_valid_response[command] = True
3024                            continue
3025                    else:
3026                        logger.write_warning_to_html_report(
3027                            f"Did not receive any response for PGN {command} ({command_name})"
3028                        )
3029                        no_valid_response[command] = True
3030                        average_times[command] = "No response"
3031                        continue
3032
3033            for pgn, item in times_list.items():
3034                times = item
3035                average = sum(times) / len(times)
3036                average_times[pgn] = average
3037                message = f"PGN {pgn} ({PGN[pgn].name}) average time: {average:.6f} seconds"
3038                logger.write_result_to_html_report(message)
3039
3040            _GENERATED_PROFILE.average_times = average_times
Description Analyze timing of commands
GitHub Issue turnaroundfactor/BMS-HW-Test#383
Instructions 1. Make requests for supported commands
2. Conduct timing analysis
3. Log average times
Estimated Duration 35 seconds
def test_comparison(self) -> None:
3042    def test_comparison(self) -> None:
3043        """
3044        | Description          | Compare average time values                                      |
3045        | :------------------- | :--------------------------------------------------------------- |
3046        | GitHub Issue         | turnaroundfactor/BMS-HW-Test#393                                 |
3047        | Instructions         | 1. Check if profiles complete commands in approx. same time      |
3048        | Pass / Fail Criteria | Pass if profiles time completion are closely matched             |
3049        | Estimated Duration   | 1 second                                                         |
3050        """
3051
3052        if not hasattr(_GENERATED_PROFILE, "average_times") or not hasattr(_COMPARISON_PROFILE, "average_times"):
3053            message = "Nothing to compare. The profile test may have failed or been skipped"
3054            logger.write_failure_to_html_report(message)
3055            pytest.fail(message)
3056
3057        generated_averages = _GENERATED_PROFILE.average_times
3058        comparison_averages = _COMPARISON_PROFILE.average_times
3059
3060        averages_not_close = []
3061        missing_commands = []
3062        percent_closeness = 0.50
3063        for pgn in generated_averages:
3064            if not comparison_averages.get(str(pgn)):
3065                logger.write_warning_to_html_report(f"Missing PGN {pgn} ({PGN[pgn].name}) from comparison profile")
3066                missing_commands.append(pgn)
3067                continue
3068
3069            if isinstance(generated_averages[pgn], str) or isinstance(comparison_averages[str(pgn)], str):
3070                if generated_averages[pgn] != comparison_averages[str(pgn)]:
3071                    logger.write_warning_to_html_report(
3072                        f"PGN {pgn} ({PGN[pgn].name}): {generated_averages[pgn]}{comparison_averages[str(pgn)]}"
3073                    )
3074                else:
3075                    logger.write_result_to_html_report(
3076                        f"PGN {pgn} ({PGN[pgn].name}): {generated_averages[pgn]} = {comparison_averages[str(pgn)]}"
3077                    )
3078                continue
3079
3080            diff = abs(comparison_averages[str(pgn)] - generated_averages[pgn])
3081            average = (generated_averages[pgn] + comparison_averages[str(pgn)]) / 2
3082            percentage = round((diff / average), 4)
3083            comparison = cmp(
3084                percentage * 100,
3085                "<=",
3086                percent_closeness * 100,
3087                "%",
3088            )
3089
3090            if not math.isclose(
3091                generated_averages[pgn],
3092                comparison_averages[str(pgn)],
3093                rel_tol=percent_closeness,
3094            ):
3095                logger.write_warning_to_html_report(f"PGN {pgn} ({PGN[pgn].name}) average percentage: {comparison}")
3096                averages_not_close.append(pgn)
3097            else:
3098                logger.write_result_to_html_report(f"PGN {pgn} ({PGN[pgn].name}) average percentage: {comparison}")
3099
3100        length_not_close = len(averages_not_close)
3101        if length_not_close > 0:
3102            logger.write_failure_to_html_report(
3103                f"{length_not_close} comparison{'s' if length_not_close > 1 else ''} "
3104                f"did not meet expectations. See warnings above for more details."
3105            )
3106            pytest.fail("Some comparison times were not within range")
3107
3108        if len(missing_commands) > 0:
3109            logger.write_failure_to_html_report(f"{len(missing_commands)} PGNs were missing from comparison profile")
3110
3111        logger.write_result_to_html_report(
3112            f"All PGN Average times for profiles were within expected range of {percent_closeness * 100}%"
3113        )
Description Compare average time values
GitHub Issue turnaroundfactor/BMS-HW-Test#393
Instructions 1. Check if profiles complete commands in approx. same time
Pass / Fail Criteria Pass if profiles time completion are closely matched
Estimated Duration 1 second
def memory_test(mode: Modes) -> list[int]:
3116def memory_test(mode: Modes) -> list[int]:
3117    """Memory tester helper"""
3118    found_addresses = []
3119    with CANBus(BATTERY_CHANNEL) as bus:
3120        read_request_frame = CANFrame(pgn=PGN["DM14"], data=[1, 1, 1, 0, 0, 0, 0, 0])
3121        read_request_frame.data[2] = mode
3122        addresses = [0, 1107296256, 2147483648, 4294966271]  # 0x0, 0x42000000, 0x80000000, 0xfffffbff
3123        for low_address in addresses:
3124            found = 0
3125            high_address = low_address + (1024 if FULL_MEMORY_TESTS else 16)
3126            for i in range(low_address, high_address):
3127                read_request_frame.data[5] = low_address
3128                if response_frame := bus.process_call(read_request_frame, timeout=1):  # NOTE: SAFT times out
3129                    if response_frame is not None:
3130                        logger.write_info_to_report(
3131                            f"Address {i} responded with PGN {response_frame.pgn.id} "
3132                            f"({response_frame.pgn.short_name}) - status is: "
3133                            f"{spn_types.dm15_status(response_frame.data[3])}"
3134                        )
3135                        if response_frame.pgn.id == PGN["DM15"].id:
3136                            if response_frame.data[5] != 258:  # Invalid Length
3137                                if response_frame.data[3] == 0:
3138                                    found_addresses.append(i)
3139                                    found += 1
3140                        else:
3141                            Errors.unexpected_packet("DM15", response_frame)
3142                            break
3143                    else:
3144                        Errors.no_packet("DM15")
3145                        break
3146                else:
3147                    # timeout
3148                    pass
3149            verb = ""
3150            match mode:
3151                case Modes.READ:
3152                    verb = "readable"
3153                case Modes.WRITE:
3154                    verb = "writable"
3155                case Modes.ERASE:
3156                    verb = "erasable"
3157                case Modes.BOOT:
3158                    verb = "boot load"
3159            message = (
3160                f"Found {found} {verb} successful address(es) in memory ranges"
3161                f" {hex(low_address)}-{hex(high_address)}"
3162            )
3163            logger.write_result_to_html_report(message)
3164
3165    if len(found_addresses) > 0:
3166        logger.write_result_to_html_report(
3167            f"Found {len(found_addresses)} {verb} memory address(es) out of "
3168            f"{4096 if FULL_MEMORY_TESTS else 64} possible addresses"
3169        )
3170    else:
3171        message = f"Found 0 {verb} memory addresses out of {4096 if FULL_MEMORY_TESTS else 64} possible addresses"
3172        logger.write_warning_to_html_report(message)
3173    return found_addresses

Memory tester helper

class TestMemoryRead:
3176class TestMemoryRead:
3177    """This will test the read capability of the memory"""
3178
3179    @pytest.mark.profile
3180    def test_read(self) -> None:
3181        """
3182        | Description          | Try to read from different memory locations                      |
3183        | :------------------- | :--------------------------------------------------------------- |
3184        | GitHub Issue         | turnaroundfactor/BMS-HW-Test#379                            </br>\
3185                                 turnaroundfactor/BMS-HW-Test#380                                 |
3186        | Instructions         | 1. Request memory read                                      </br>\
3187                                 2. Log any successful addresses                                  |
3188        | Estimated Duration   | 85 seconds                                                       |
3189        """
3190
3191        _GENERATED_PROFILE.read_addresses = memory_test(Modes.READ)
3192
3193    def test_comparison(self) -> None:
3194        """
3195        | Description          | Compare read results                                             |
3196        | :------------------- | :--------------------------------------------------------------- |
3197        | GitHub Issue         | turnaroundfactor/BMS-HW-Test#379                            </br>\
3198                                 turnaroundfactor/BMS-HW-Test#380                                 |
3199        | Instructions         | Confirm no new memory addresses are readable                     |
3200        | Pass / Fail Criteria | Pass if no new readable memory addresses                         |
3201        | Estimated Duration   | 1 second                                                         |
3202        """
3203
3204        if hasattr(_GENERATED_PROFILE, "read_addresses") and hasattr(_COMPARISON_PROFILE, "read_addresses"):
3205            new_addresses = set(_GENERATED_PROFILE.read_addresses) - set(_COMPARISON_PROFILE.read_addresses)
3206            if new_addresses:
3207                message = (
3208                    f"{len(new_addresses)} Different readable memory address(es) found: "
3209                    f"{', '.join(map(str, new_addresses))}"
3210                )
3211                logger.write_failure_to_html_report(message)
3212                pytest.fail(message)
3213
3214            logger.write_result_to_html_report("Profiles had matching readable memory address responses")
3215
3216        else:
3217            message = "Nothing to compare. The profile test may have failed or been skipped"
3218            logger.write_result_to_html_report(message)
3219            pytest.skip(message)

This will test the read capability of the memory

@pytest.mark.profile
def test_read(self) -> None:
3179    @pytest.mark.profile
3180    def test_read(self) -> None:
3181        """
3182        | Description          | Try to read from different memory locations                      |
3183        | :------------------- | :--------------------------------------------------------------- |
3184        | GitHub Issue         | turnaroundfactor/BMS-HW-Test#379                            </br>\
3185                                 turnaroundfactor/BMS-HW-Test#380                                 |
3186        | Instructions         | 1. Request memory read                                      </br>\
3187                                 2. Log any successful addresses                                  |
3188        | Estimated Duration   | 85 seconds                                                       |
3189        """
3190
3191        _GENERATED_PROFILE.read_addresses = memory_test(Modes.READ)
Description Try to read from different memory locations
GitHub Issue turnaroundfactor/BMS-HW-Test#379
turnaroundfactor/BMS-HW-Test#380
Instructions 1. Request memory read
2. Log any successful addresses
Estimated Duration 85 seconds
def test_comparison(self) -> None:
3193    def test_comparison(self) -> None:
3194        """
3195        | Description          | Compare read results                                             |
3196        | :------------------- | :--------------------------------------------------------------- |
3197        | GitHub Issue         | turnaroundfactor/BMS-HW-Test#379                            </br>\
3198                                 turnaroundfactor/BMS-HW-Test#380                                 |
3199        | Instructions         | Confirm no new memory addresses are readable                     |
3200        | Pass / Fail Criteria | Pass if no new readable memory addresses                         |
3201        | Estimated Duration   | 1 second                                                         |
3202        """
3203
3204        if hasattr(_GENERATED_PROFILE, "read_addresses") and hasattr(_COMPARISON_PROFILE, "read_addresses"):
3205            new_addresses = set(_GENERATED_PROFILE.read_addresses) - set(_COMPARISON_PROFILE.read_addresses)
3206            if new_addresses:
3207                message = (
3208                    f"{len(new_addresses)} Different readable memory address(es) found: "
3209                    f"{', '.join(map(str, new_addresses))}"
3210                )
3211                logger.write_failure_to_html_report(message)
3212                pytest.fail(message)
3213
3214            logger.write_result_to_html_report("Profiles had matching readable memory address responses")
3215
3216        else:
3217            message = "Nothing to compare. The profile test may have failed or been skipped"
3218            logger.write_result_to_html_report(message)
3219            pytest.skip(message)
Description Compare read results
GitHub Issue turnaroundfactor/BMS-HW-Test#379
turnaroundfactor/BMS-HW-Test#380
Instructions Confirm no new memory addresses are readable
Pass / Fail Criteria Pass if no new readable memory addresses
Estimated Duration 1 second
class TestMemoryWrite:
3222class TestMemoryWrite:
3223    """This will test the write capability of the memory"""
3224
3225    @pytest.mark.profile
3226    def test_write(self) -> None:
3227        """
3228        | Description          | Try to write to different memory locations                       |
3229        | :------------------- | :--------------------------------------------------------------- |
3230        | GitHub Issue         | turnaroundfactor/BMS-HW-Test#379                            </br>\
3231                                 turnaroundfactor/BMS-HW-Test#380                                 |
3232        | Instructions         | 1. Request memory write                                     </br>\
3233                                 2. Log any successful addresses                                  |
3234        | Estimated Duration   | 85 seconds                                                       |
3235        """
3236
3237        _GENERATED_PROFILE.write_addresses = memory_test(Modes.WRITE)
3238
3239    def test_comparison(self) -> None:
3240        """
3241        | Description          | Compare write results                                            |
3242        | :------------------- | :--------------------------------------------------------------- |
3243        | GitHub Issue         | turnaroundfactor/BMS-HW-Test#379                            </br>\
3244                                 turnaroundfactor/BMS-HW-Test#380                                 |
3245        | Instructions         | Confirm no new memory addresses are writable                     |
3246        | Pass / Fail Criteria | Pass if no new writable memory addresses                         |
3247        | Estimated Duration   | 1 second                                                         |
3248        """
3249
3250        if hasattr(_GENERATED_PROFILE, "write_addresses") and hasattr(_COMPARISON_PROFILE, "write_addresses"):
3251            new_addresses = set(_GENERATED_PROFILE.write_addresses) - set(_COMPARISON_PROFILE.write_addresses)
3252            if new_addresses:
3253                message = (
3254                    f"{len(new_addresses)} Different writable memory address(es) found: "
3255                    f"{', '.join(map(str, new_addresses))}"
3256                )
3257                logger.write_failure_to_html_report(message)
3258                pytest.fail(message)
3259
3260            logger.write_result_to_html_report("Profiles had matching writable memory address responses")
3261
3262        else:
3263            message = "Nothing to compare. The profile test may have failed or been skipped"
3264            logger.write_result_to_html_report(message)
3265            pytest.skip(message)

This will test the write capability of the memory

@pytest.mark.profile
def test_write(self) -> None:
3225    @pytest.mark.profile
3226    def test_write(self) -> None:
3227        """
3228        | Description          | Try to write to different memory locations                       |
3229        | :------------------- | :--------------------------------------------------------------- |
3230        | GitHub Issue         | turnaroundfactor/BMS-HW-Test#379                            </br>\
3231                                 turnaroundfactor/BMS-HW-Test#380                                 |
3232        | Instructions         | 1. Request memory write                                     </br>\
3233                                 2. Log any successful addresses                                  |
3234        | Estimated Duration   | 85 seconds                                                       |
3235        """
3236
3237        _GENERATED_PROFILE.write_addresses = memory_test(Modes.WRITE)
Description Try to write to different memory locations
GitHub Issue turnaroundfactor/BMS-HW-Test#379
turnaroundfactor/BMS-HW-Test#380
Instructions 1. Request memory write
2. Log any successful addresses
Estimated Duration 85 seconds
def test_comparison(self) -> None:
3239    def test_comparison(self) -> None:
3240        """
3241        | Description          | Compare write results                                            |
3242        | :------------------- | :--------------------------------------------------------------- |
3243        | GitHub Issue         | turnaroundfactor/BMS-HW-Test#379                            </br>\
3244                                 turnaroundfactor/BMS-HW-Test#380                                 |
3245        | Instructions         | Confirm no new memory addresses are writable                     |
3246        | Pass / Fail Criteria | Pass if no new writable memory addresses                         |
3247        | Estimated Duration   | 1 second                                                         |
3248        """
3249
3250        if hasattr(_GENERATED_PROFILE, "write_addresses") and hasattr(_COMPARISON_PROFILE, "write_addresses"):
3251            new_addresses = set(_GENERATED_PROFILE.write_addresses) - set(_COMPARISON_PROFILE.write_addresses)
3252            if new_addresses:
3253                message = (
3254                    f"{len(new_addresses)} Different writable memory address(es) found: "
3255                    f"{', '.join(map(str, new_addresses))}"
3256                )
3257                logger.write_failure_to_html_report(message)
3258                pytest.fail(message)
3259
3260            logger.write_result_to_html_report("Profiles had matching writable memory address responses")
3261
3262        else:
3263            message = "Nothing to compare. The profile test may have failed or been skipped"
3264            logger.write_result_to_html_report(message)
3265            pytest.skip(message)
Description Compare write results
GitHub Issue turnaroundfactor/BMS-HW-Test#379
turnaroundfactor/BMS-HW-Test#380
Instructions Confirm no new memory addresses are writable
Pass / Fail Criteria Pass if no new writable memory addresses
Estimated Duration 1 second
class TestMemoryErase:
3268class TestMemoryErase:
3269    """This will test the erase capability of the memory"""
3270
3271    @pytest.mark.profile
3272    def test_erase(self) -> None:
3273        """
3274        | Description          | Try to erase different memory locations                          |
3275        | :------------------- | :--------------------------------------------------------------- |
3276        | GitHub Issue         | turnaroundfactor/BMS-HW-Test#379                            </br>\
3277                                 turnaroundfactor/BMS-HW-Test#380                                 |
3278        | Instructions         | 1. Request memory erase                                     </br>\
3279                                 2. Log any successful addresses                                  |
3280        | Estimated Duration   | 85 seconds                                                       |
3281        """
3282
3283        _GENERATED_PROFILE.erase_addresses = memory_test(Modes.ERASE)
3284
3285    def test_comparison(self) -> None:
3286        """
3287        | Description          | Compare erase results                                            |
3288        | :------------------- | :--------------------------------------------------------------- |
3289        | GitHub Issue         | turnaroundfactor/BMS-HW-Test#379                            </br>\
3290                                 turnaroundfactor/BMS-HW-Test#380                                 |
3291        | Instructions         | Confirm no new memory addresses are erasable                     |
3292        | Pass / Fail Criteria | Pass if no new erasable memory addresses                         |
3293        | Estimated Duration   | 1 second                                                         |
3294        """
3295
3296        if hasattr(_GENERATED_PROFILE, "erase_addresses") and hasattr(_COMPARISON_PROFILE, "erase_addresses"):
3297            new_addresses = set(_GENERATED_PROFILE.erase_addresses) - set(_COMPARISON_PROFILE.erase_addresses)
3298            if new_addresses:
3299                message = (
3300                    f"{len(new_addresses)} Different erasable memory address(es) found: "
3301                    f"{', '.join(map(str, new_addresses))}"
3302                )
3303                logger.write_failure_to_html_report(message)
3304                pytest.fail(message)
3305
3306            logger.write_result_to_html_report("Profiles had matching erasable memory address responses")
3307
3308        else:
3309            message = "Nothing to compare. The profile test may have failed or been skipped"
3310            logger.write_result_to_html_report(message)
3311            pytest.skip(message)

This will test the erase capability of the memory

@pytest.mark.profile
def test_erase(self) -> None:
3271    @pytest.mark.profile
3272    def test_erase(self) -> None:
3273        """
3274        | Description          | Try to erase different memory locations                          |
3275        | :------------------- | :--------------------------------------------------------------- |
3276        | GitHub Issue         | turnaroundfactor/BMS-HW-Test#379                            </br>\
3277                                 turnaroundfactor/BMS-HW-Test#380                                 |
3278        | Instructions         | 1. Request memory erase                                     </br>\
3279                                 2. Log any successful addresses                                  |
3280        | Estimated Duration   | 85 seconds                                                       |
3281        """
3282
3283        _GENERATED_PROFILE.erase_addresses = memory_test(Modes.ERASE)
Description Try to erase different memory locations
GitHub Issue turnaroundfactor/BMS-HW-Test#379
turnaroundfactor/BMS-HW-Test#380
Instructions 1. Request memory erase
2. Log any successful addresses
Estimated Duration 85 seconds
def test_comparison(self) -> None:
3285    def test_comparison(self) -> None:
3286        """
3287        | Description          | Compare erase results                                            |
3288        | :------------------- | :--------------------------------------------------------------- |
3289        | GitHub Issue         | turnaroundfactor/BMS-HW-Test#379                            </br>\
3290                                 turnaroundfactor/BMS-HW-Test#380                                 |
3291        | Instructions         | Confirm no new memory addresses are erasable                     |
3292        | Pass / Fail Criteria | Pass if no new erasable memory addresses                         |
3293        | Estimated Duration   | 1 second                                                         |
3294        """
3295
3296        if hasattr(_GENERATED_PROFILE, "erase_addresses") and hasattr(_COMPARISON_PROFILE, "erase_addresses"):
3297            new_addresses = set(_GENERATED_PROFILE.erase_addresses) - set(_COMPARISON_PROFILE.erase_addresses)
3298            if new_addresses:
3299                message = (
3300                    f"{len(new_addresses)} Different erasable memory address(es) found: "
3301                    f"{', '.join(map(str, new_addresses))}"
3302                )
3303                logger.write_failure_to_html_report(message)
3304                pytest.fail(message)
3305
3306            logger.write_result_to_html_report("Profiles had matching erasable memory address responses")
3307
3308        else:
3309            message = "Nothing to compare. The profile test may have failed or been skipped"
3310            logger.write_result_to_html_report(message)
3311            pytest.skip(message)
Description Compare erase results
GitHub Issue turnaroundfactor/BMS-HW-Test#379
turnaroundfactor/BMS-HW-Test#380
Instructions Confirm no new memory addresses are erasable
Pass / Fail Criteria Pass if no new erasable memory addresses
Estimated Duration 1 second
class TestMemoryBootLoad:
3314class TestMemoryBootLoad:
3315    """This will test the boot load capability of the memory"""
3316
3317    @pytest.mark.profile
3318    def test_boot(self) -> None:
3319        """
3320        | Description          | Try to boot load from different memory locations                 |
3321        | :------------------- | :--------------------------------------------------------------- |
3322        | GitHub Issue         | turnaroundfactor/BMS-HW-Test#381                            </br>\
3323                                 turnaroundfactor/BMS-HW-Test#382                                 |
3324        | Instructions         | 1. Request memory boot load                                 </br>\
3325                                 2. Log any successful addresses                                  |
3326        | Estimated Duration   | 85 seconds                                                       |
3327        """
3328
3329        _GENERATED_PROFILE.boot_addresses = memory_test(Modes.BOOT)
3330
3331    def test_comparison(self) -> None:
3332        """
3333        | Description          | Compare boot load results                                        |
3334        | :------------------- | :--------------------------------------------------------------- |
3335        | GitHub Issue         | turnaroundfactor/BMS-HW-Test#381                            </br>\
3336                                 turnaroundfactor/BMS-HW-Test#382                                 |
3337        | Instructions         | Confirm no new memory addresses are boot load capable            |
3338        | Pass / Fail Criteria | Pass if no new boot load memory addresses                        |
3339        | Estimated Duration   | 1 second                                                         |
3340        """
3341
3342        if hasattr(_GENERATED_PROFILE, "boot_addresses") and hasattr(_COMPARISON_PROFILE, "boot_addresses"):
3343            new_addresses = set(_GENERATED_PROFILE.boot_addresses) - set(_COMPARISON_PROFILE.boot_addresses)
3344            if new_addresses:
3345                message = (
3346                    f"{len(new_addresses)} Different boot memory address(es) found: "
3347                    f"{', '.join(map(str, new_addresses))}"
3348                )
3349                logger.write_failure_to_html_report(message)
3350                pytest.fail(message)
3351
3352            logger.write_result_to_html_report("Profiles had matching boot memory address responses")
3353
3354        else:
3355            message = "Nothing to compare. The profile test may have failed or been skipped"
3356            logger.write_result_to_html_report(message)
3357            pytest.skip(message)

This will test the boot load capability of the memory

@pytest.mark.profile
def test_boot(self) -> None:
3317    @pytest.mark.profile
3318    def test_boot(self) -> None:
3319        """
3320        | Description          | Try to boot load from different memory locations                 |
3321        | :------------------- | :--------------------------------------------------------------- |
3322        | GitHub Issue         | turnaroundfactor/BMS-HW-Test#381                            </br>\
3323                                 turnaroundfactor/BMS-HW-Test#382                                 |
3324        | Instructions         | 1. Request memory boot load                                 </br>\
3325                                 2. Log any successful addresses                                  |
3326        | Estimated Duration   | 85 seconds                                                       |
3327        """
3328
3329        _GENERATED_PROFILE.boot_addresses = memory_test(Modes.BOOT)
Description Try to boot load from different memory locations
GitHub Issue turnaroundfactor/BMS-HW-Test#381
turnaroundfactor/BMS-HW-Test#382
Instructions 1. Request memory boot load
2. Log any successful addresses
Estimated Duration 85 seconds
def test_comparison(self) -> None:
3331    def test_comparison(self) -> None:
3332        """
3333        | Description          | Compare boot load results                                        |
3334        | :------------------- | :--------------------------------------------------------------- |
3335        | GitHub Issue         | turnaroundfactor/BMS-HW-Test#381                            </br>\
3336                                 turnaroundfactor/BMS-HW-Test#382                                 |
3337        | Instructions         | Confirm no new memory addresses are boot load capable            |
3338        | Pass / Fail Criteria | Pass if no new boot load memory addresses                        |
3339        | Estimated Duration   | 1 second                                                         |
3340        """
3341
3342        if hasattr(_GENERATED_PROFILE, "boot_addresses") and hasattr(_COMPARISON_PROFILE, "boot_addresses"):
3343            new_addresses = set(_GENERATED_PROFILE.boot_addresses) - set(_COMPARISON_PROFILE.boot_addresses)
3344            if new_addresses:
3345                message = (
3346                    f"{len(new_addresses)} Different boot memory address(es) found: "
3347                    f"{', '.join(map(str, new_addresses))}"
3348                )
3349                logger.write_failure_to_html_report(message)
3350                pytest.fail(message)
3351
3352            logger.write_result_to_html_report("Profiles had matching boot memory address responses")
3353
3354        else:
3355            message = "Nothing to compare. The profile test may have failed or been skipped"
3356            logger.write_result_to_html_report(message)
3357            pytest.skip(message)
Description Compare boot load results
GitHub Issue turnaroundfactor/BMS-HW-Test#381
turnaroundfactor/BMS-HW-Test#382
Instructions Confirm no new memory addresses are boot load capable
Pass / Fail Criteria Pass if no new boot load memory addresses
Estimated Duration 1 second