Build a Vehicle Telemetry Decoder
Build a Python decoder that turns fictional hex frames into tested readings.
Introduction
30 Second Summary
A short string like 2D96 means little until something translates each part into a value people can read. That translation becomes harder when two data layouts place the same values in different positions.
In this project, you will build a fictional vehicle telemetry decoder with Python. It will turn hexadecimal frames into readable values using reusable JSON profiles.
What You'll Build
You'll run one decoder with two fictional frame layouts and watch both produce the same readable telemetry values.
By the end of this project, you'll have:
- A working telemetry decoder that turns 2D96 into demo_speed: 90 km/h plus demo_tank_level: 75 %.
- Reusable profile-driven layouts that decode 962D into the same physical values despite its reversed byte order.
- An automated test suite that proves both profiles work while malformed frames are rejected.
- Secret Mission: Add a fictional temperature signal entirely through JSON and prove it with a fifth passing test.
Are there any prerequisites?
You'll need a Mac with administrator permission to install Python 3.15.2 and its bundled IDLE editor. No Python experience or vehicle hardware is required.
Before We Start
Before you begin, lock in what you are building and why its decoding rules stay separate from the Python logic. This keeps fictional teaching data distinct from licensed or unverified vehicle definitions.
Set Up Python and IDLE
Your decoder needs a known Python runtime before it can process a frame. The official macOS package supplies Python and IDLE together.
You will use macOS Terminal to verify the exact release. You will also prepare an empty decoder.py file for the decoder you build next.
In this step, get ready to:
- Install Python 3.15.2 from the official macOS package.
- Install Python's certificates.
- Create the telemetry_decoder folder with an empty decoder.py file.
Install Python 3.15.2
A version check reveals whether the required interpreter already exists on your Mac. This prevents a different Python release from changing how later steps behave.
- Press Cmd+Space to open Spotlight.
- Type Terminal into Spotlight.
- Press Return to open Terminal.
- Check the installed Python release by running this command:
python3.15 -VV
What does this command check?
- The python3.15 command targets the Python 3.15 interpreter.
- The -VV option prints detailed version information.
- The output determines which setup path you need.
✔️ I see the required version
The output begins with Python 3.15.2. Your interpreter is ready for this project.
ⓧ I see an older version
The installer may request administrator approval. This approval allows the package to install the required Python release on your Mac.
- Visit the official Python downloads page.
- Download the Python 3.15.2 macOS .pkg installer.
- Open the downloaded .pkg in Finder.
- Select Continue in the installer.
- Select Agree when the license appears.
- Select Install.
- Approve the installation with a macOS administrator account if prompted.
- Wait for the installer to report completion.
ⓧ Command not found
Your Mac may ask for administrator approval during installation. That prompt is an expected part of adding Python.
- Visit the official Python downloads page.
- Download the Python 3.15.2 macOS .pkg installer.
- Open the downloaded .pkg in Finder.
- Select Continue in the installer.
- Select Agree when the license appears.
- Select Install.
- Approve the installation with a macOS administrator account if prompted.
- Wait for the installer to report completion.
Before you verify the installation, do you expect Terminal to report the release you just installed?
- Verify the Python installation by running the command again:
python3.15 -VV
What should I see?
The output begins with Python 3.15.2. This confirms that the required interpreter responds in Terminal.
That is the first foundation in place. Your Mac now has the exact Python release used throughout this project.
Still Seeing the Wrong Version?
- Close the current Terminal window.
- Open a fresh Terminal window through Spotlight.
- Run the version check again.
If the output still does not begin with the required release, help me troubleshoot the Python installation.
Install certificates and open IDLE
Python's certificate helper prepares trusted digital certificates for secure internet connections. The helper runs in a Terminal window and reports when the setup finishes.
- Return to Finder.
- Navigate to /Applications/Python 3.15/.
- Double-click Install Certificates.command.
- Wait for the certificate process to finish.
The final output includes Successfully installed certifi. A later line includes update complete.
Certificate Setup Did Not Complete?
- Confirm that your Mac has an internet connection.
- Return to /Applications/Python 3.15/ in Finder.
- Run Install Certificates.command again.
If the completion messages still do not appear, help me troubleshoot the certificate setup.
- Return to the Finder window showing /Applications/Python 3.15/.
- Double-click IDLE.
You will see the IDLE Shell with a >>> prompt. This prompt confirms that the interactive Python Shell is ready.
Create the project workspace
The telemetry_decoder folder keeps every project file together. The empty decoder.py file gives IDLE a saved starting point for the first decoder.
- Switch to Finder.
- Navigate to your Desktop in Finder.
- Open Finder's File menu.
- Choose the option that creates a new folder.
- Enter telemetry_decoder as the folder name.
- Press Return to finish naming the folder.
You will see the telemetry_decoder folder on your Desktop.
- Return to the IDLE Shell.
- Select New File from IDLE's File menu.
You will see a blank IDLE editor window. This separate window holds the Python file you edit.
- Select Save As… from the editor's File menu.
- Navigate to the telemetry_decoder folder on your Desktop.
- Enter decoder.py as the filename.
- Complete the save.
You will see decoder.py open in IDLE with a completely blank editing area.
File Saved in the Wrong Place?
- Select Save As… again.
- Navigate to the telemetry_decoder folder on your Desktop.
- Save the file as decoder.py.
If the file still appears elsewhere, help me check the IDLE save location.
✔️ Awesome, I've got everything!
Your empty decoder.py file is saved inside the telemetry_decoder folder. Keep the editor window open for the next step.
ⓧ I'd like to double check the full code
The complete decoder.py file contains zero characters. The IDLE editing area should be completely blank.
Why Is This File Empty?
This step prepares the workspace only. The next step adds the first runnable decoder to this blank file.
Before the final check, what version should Terminal report when the workspace is ready?
- Return to the Terminal window from earlier.
- Confirm the Python release by running this command:
python3.15 -VV
Confirm the Interpreter
You will see output beginning with Python 3.15.2. This proves the required interpreter remains available after setup.
- Open the telemetry_decoder folder in Finder.
You will see decoder.py listed inside the folder.
- Return to the IDLE editor.
You will see the empty decoder.py file open for editing.
- Switch to the IDLE Shell window.
You will see the >>> prompt waiting for input.
Your Python toolchain and IDLE workspace are ready. Next up, you will turn decoder.py into a visible two-value decoder.
Decode Your First Demo Frame
Your Python environment is ready in IDLE. The empty decoder.py file is waiting for the first working slice of your telemetry decoder.
This temporary decoder keeps its byte positions inside one function. It also keeps its calculations there.
The hard-coded design gives you an immediate two-value result. You will test its limitation in the next step.
In this step, get ready to:
- Convert a hexadecimal frame into two raw byte values.
- Calculate two fictional telemetry readings from the raw bytes.
- Verify the decoder's output with 2D96.
Decode the frame bytes
A hexadecimal frame represents each byte with two hexadecimal characters. bytes.fromhex turns 2D96 into two bytes that Python can index.
- Add the temporary decode_frame function to decoder.py by entering this code:
def decode_frame(hex_frame):
payload = bytes.fromhex(hex_frame)
raw_speed = payload[0]
raw_tank = payload[1]
return {
"demo_speed": {"value": raw_speed * 2, "unit": "km/h"},
"demo_tank_level": {"value": raw_tank * 0.5, "unit": "%"},
}
How the Bytes Become Readings
- The bytes.fromhex conversion turns the hexadecimal text into a byte sequence.
- The payload[0] lookup reads the first byte as the fictional raw speed.
- The payload[1] lookup reads the second byte as the fictional raw tank level.
- The raw_speed * 2 calculation produces the fictional speed value.
- The raw_tank * 0.5 calculation produces the fictional tank value.
- The returned dictionary keeps each calculated value beside its fictional unit.
- Save decoder.py.
- Select Run Module from the Run menu.
IDLE restarts the Shell. You will see a fresh >>> prompt.
Good progress. The decoder function now loads without a syntax problem.
Does the Function Fail to Load?
- Compare each line in decoder.py with the code block.
- Check that the indented lines use the same spacing.
- Confirm that the returned dictionary has both closing braces.
Help me find the syntax problem in my decode_frame function.
Format the readings
The decoder currently returns structured data. format_readings turns that structure into one readable line per signal.
- Place your cursor on the blank line below the closing brace of decode_frame.
- Add the formatter by entering this code:
def format_readings(readings):
lines = []
for name, reading in readings.items():
lines.append(f"{name}: {reading['value']:g} {reading['unit']}")
return "\n".join(lines)
How the Formatter Builds Output
- The lines list collects one formatted result for each signal.
- The loop reads each signal name from the returned dictionary.
- The formatted string places the value beside its fictional unit.
- The final join combines the results with one reading per line.
- Save decoder.py.
- Select Run Module from the Run menu.
The Shell restarts with a fresh >>> prompt. This confirms that both functions load successfully.
Does the Formatter Fail to Load?
- Check that format_readings starts at the far left of the editor.
- Confirm that the line beginning with lines.append stays inside the loop.
- Compare the quotation marks around the formatted string with the code block.
Help me debug my format_readings function.
Run the complete decoder
The main function connects the Shell prompt to the two functions you created. The final guard tells Python to call main when you run decoder.py.
- Place your cursor on the blank line below format_readings.
- Add the program entry point by entering this code:
def main():
hex_frame = input("Hex frame: ").strip()
print(format_readings(decode_frame(hex_frame)))
if __name__ == "__main__":
main()
How the Script Starts
- The input call collects the fictional hexadecimal frame.
- The strip call removes surrounding whitespace from the input.
- The nested function call decodes the frame before formatting the readings.
- The final guard starts main when IDLE runs the file as a module.
- Save decoder.py.
Before you run the finished decoder, what two physical values do you expect the raw bytes in 2D96 to produce?
- Select Run Module from the Run menu.
- Enter 2D96 at the Hex frame: prompt.
You will see demo_speed: 90 km/h on one line. You will see demo_tank_level: 75 % on the next line.
You have your first working telemetry result. The temporary decoder now turns a fictional hexadecimal frame into readable values.
Do the Readings Look Different?
- Confirm that you entered 2D96 at the frame prompt.
- Check that raw_speed uses payload[0].
- Check that raw_tank uses payload[1].
Help me debug the output from my temporary telemetry decoder.
✔️ Awesome, I've got everything!
Your decoder.py file now contains the complete temporary hard-coded decoder.
ⓧ I'd like to double check the full code
def decode_frame(hex_frame):
payload = bytes.fromhex(hex_frame)
raw_speed = payload[0]
raw_tank = payload[1]
return {
"demo_speed": {"value": raw_speed * 2, "unit": "km/h"},
"demo_tank_level": {"value": raw_tank * 0.5, "unit": "%"},
}
def format_readings(readings):
lines = []
for name, reading in readings.items():
lines.append(f"{name}: {reading['value']:g} {reading['unit']}")
return "\n".join(lines)
def main():
hex_frame = input("Hex frame: ").strip()
print(format_readings(decode_frame(hex_frame)))
if __name__ == "__main__":
main()
Your decoder now turns 2D96 into two readable fictional values. Next, you will run an alternate frame through the unchanged code.
Expose the Hard-Coded Layout
Your hard-coded decoder already turns 2D96 into a speed reading of 90 km/h. It also produces a tank reading of 75 %.
The alternate frame stores those same fictional raw values in reverse byte order. This step tests whether the embedded byte positions can adapt.
In this step, get ready to:
- Test the unchanged decoder with the reversed frame 962D.
- Record the intended reversed layout in alternate_profile.json.
- Trace the mismatch to the embedded payload positions.
Run the reversed frame
A byte index is the numbered position of a byte in the frame. The current function assigns fixed meanings to those positions.
Before you run the decoder, do you think reversing the two bytes will preserve the original readings?
- Return to the decoder.py editor tab in IDLE.
- Select Run from the menu bar.
- Select Run Module.
- Enter 962D when the Shell shows Hex frame:.
- Press Return.
You'll see demo_speed: 300 km/h. The next line reads demo_tank_level: 22.5 %.
That mismatch is the result this step needs. You have proved that the first frame only worked because its layout matched the fixed Python assumptions.
Why are these readings wrong?
- The first byte in 962D is 96. Its fictional tank calculation produces 75 %.
- The second byte is 2D. Its fictional speed calculation produces 90 km/h.
- The hard-coded function still treats the first byte as speed.
- The hard-coded function still treats the second byte as tank level.
Describe the alternate layout in JSON
A JSON profile can describe what each byte means outside the decoder. For this alternate layout, tank level belongs at index 0.
- Select New File from the IDLE menu bar.
- Add the reversed layout by pasting this JSON:
{
"profile_name": "NextWork Fictional Alternate Layout",
"frame_bytes": 2,
"signals": [
{
"name": "demo_tank_level",
"byte_index": 0,
"scale": 0.5,
"offset": 0,
"unit": "%"
},
{
"name": "demo_speed",
"byte_index": 1,
"scale": 2,
"offset": 0,
"unit": "km/h"
}
]
}
What does this profile describe?
- The frame_bytes value defines a two-byte frame.
- The demo_tank_level signal reads byte index 0 with a scale of 0.5.
- The demo_speed signal reads byte index 1 with a scale of 2.
- The profile keeps every fictional layout rule outside the decoder.
- Select Save As… from the IDLE menu bar.
- Select the telemetry_decoder folder in the save dialog.
- Enter alternate_profile.json as the filename.
- Select Save.
- Confirm that the editor title bar shows alternate_profile.json.
Can't save the profile?
- Use Save As… again if the file landed outside telemetry_decoder.
- Confirm that the filename is exactly alternate_profile.json.
Help me save alternate_profile.json in the correct project folder.
✔️ Awesome, I've got everything!
You have now captured the reversed layout without changing the decoder.
ⓧ I'd like to double check the full code
{
"profile_name": "NextWork Fictional Alternate Layout",
"frame_bytes": 2,
"signals": [
{
"name": "demo_tank_level",
"byte_index": 0,
"scale": 0.5,
"offset": 0,
"unit": "%"
},
{
"name": "demo_speed",
"byte_index": 1,
"scale": 2,
"offset": 0,
"unit": "km/h"
}
]
}
What should match?
Your file should match this reference exactly. The reversed byte indices are the key difference from the original hard-coded layout.
Trace the embedded byte positions
The profile now records the intended meaning of each position. The current decoder still relies on assignments inside decode_frame().
- Return to the decoder.py editor tab.
- Locate these assignments:
raw_speed = payload[0]
raw_tank = payload[1]
What do these assignments assume?
- The payload[0] position always becomes raw_speed.
- The payload[1] position always becomes raw_tank.
- These fixed assignments cannot follow a layout stored in another file.
- Switch back to the alternate_profile.json editor tab.
- Compare each byte_index value with the assignments above.
In alternate_profile.json, index 0 belongs to tank level. Index 1 belongs to speed.
In decoder.py, index 0 remains speed. Index 1 remains tank level.
Before you run the decoder again, do you expect saving alternate_profile.json to change the output while main() never loads it?
- Return to the decoder.py editor tab.
- Select Run from the menu bar.
- Select Run Module.
- Enter 962D at the Hex frame: prompt.
- Press Return.
The Shell still shows demo_speed: 300 km/h. The next line still reads demo_tank_level: 22.5 %.
The unchanged decoder never loads the profile file. Its fixed assignments still control the output.
Seeing different readings?
- Confirm that decoder.py still contains the two fixed assignments shown above.
- Confirm that you entered 962D at the frame prompt.
Help me trace unexpected readings from my unchanged hard-coded decoder.
You have exposed exactly where the layout assumption lives. Next, you will make the decoder read those positions from profiles so both fictional layouts produce the intended values.
Move Rules into Profiles
The alternate frame exposed the weakness in your hard-coded decoder. Its byte positions only understand one layout.
This step moves those rules into JSON profiles. Your reusable Python code can then decode either layout without changing its logic.
In this step, get ready to:
- Create a demo profile plus a blank template for licensed definitions.
- Replace the embedded byte rules with a validated profile loader plus a generic decoder.
- Verify both fictional frame layouts with the same decoder.
Create the profile files
A profile holds the metadata that gives each byte meaning. The existing `alternate_profile.json` already describes the reversed layout from the previous step.
- Switch back to IDLE from earlier.
- Create a new editor window by selecting New File in IDLE.
- Add the original fictional layout by pasting this profile:
{
"profile_name": "NextWork Fictional Telemetry Demo",
"frame_bytes": 2,
"signals": [
{
"name": "demo_speed",
"byte_index": 0,
"scale": 2,
"offset": 0,
"unit": "km/h"
},
{
"name": "demo_tank_level",
"byte_index": 1,
"scale": 0.5,
"offset": 0,
"unit": "%"
}
]
}
What does this profile define?
- The frame_bytes value requires a two-byte frame.
- The demo_speed signal reads byte index 0.
- The demo_tank_level signal reads byte index 1.
- Each signal carries its own scale plus its own unit.
- Save the profile by selecting Save As… in IDLE.
- Choose the existing telemetry_decoder folder as the destination.
- Enter demo_profile.json as the filename.
- Confirm that the editor title shows demo_profile.json.
Profile saved in the wrong place?
Use Save As… again. Choose the same telemetry_decoder folder that contains decoder.py.
Help me place the JSON profile beside my decoder.
Real signal definitions can come from licensed sources. A blank template gives those definitions a safe destination without presenting fictional teaching data as a published standard.
- Create another editor window by selecting New File in IDLE.
- Add the blank licensed-definitions structure by pasting this template:
{
"profile_name": "Licensed Definitions Template",
"source_revision": "Enter the revision from your licensed source",
"frame_bytes": 0,
"signals": []
}
Why is this template blank?
The template provides fields for a licensed source revision plus its signal definitions. Its empty signal list prevents the project from guessing regulated identifiers or formulas.
The value 0 makes the unfinished template fail validation. Only a completed profile from an authorised source can pass.
- Save the template by selecting Save As… in IDLE.
- Choose the existing telemetry_decoder folder as the destination.
- Enter licensed_profile.template.json as the filename.
- Confirm that the editor title shows licensed_profile.template.json.
Template name looks different?
Check the complete filename in the IDLE editor title. Keep both dots in licensed_profile.template.json so its purpose remains clear.
Help me correct the template filename in IDLE.
✔️ Awesome, I've got everything!
Your three profile files now cover the original layout, the reversed layout, and a blank home for licensed definitions.
ⓧ I'd like to double check the full code
{
"profile_name": "NextWork Fictional Telemetry Demo",
"frame_bytes": 2,
"signals": [
{
"name": "demo_speed",
"byte_index": 0,
"scale": 2,
"offset": 0,
"unit": "km/h"
},
{
"name": "demo_tank_level",
"byte_index": 1,
"scale": 0.5,
"offset": 0,
"unit": "%"
}
]
}
{
"profile_name": "NextWork Fictional Alternate Layout",
"frame_bytes": 2,
"signals": [
{
"name": "demo_tank_level",
"byte_index": 0,
"scale": 0.5,
"offset": 0,
"unit": "%"
},
{
"name": "demo_speed",
"byte_index": 1,
"scale": 2,
"offset": 0,
"unit": "km/h"
}
]
}
{
"profile_name": "Licensed Definitions Template",
"source_revision": "Enter the revision from your licensed source",
"frame_bytes": 0,
"signals": []
}
Build the profile-driven decoder
The decoder needs a reliable way to find profiles beside its own file. It also needs to reject incomplete metadata before reading any frame bytes.
- Return to the existing decoder.py editor window.
- Place these imports plus profile constants at the top of the file:
import json
from pathlib import Path
BASE_DIR = Path(__file__).parent
REQUIRED_SIGNAL_KEYS = {"name", "byte_index", "scale", "offset", "unit"}
What does this setup do?
- The json module reads each profile into Python values.
- The Path class handles file locations.
- The BASE_DIR value points to the folder containing decoder.py.
- The REQUIRED_SIGNAL_KEYS set defines the metadata every signal must provide.
- Save decoder.py.
- Run the current decoder by selecting Run Module from IDLE's Run menu.
- Enter 2D96 at the Hex frame: prompt.
You still see demo_speed: 90 km/h plus demo_tank_level: 75 %. The file-location setup is working without disrupting the current decoder.
Imports causing an error?
Keep both import lines above BASE_DIR. Check that Path starts with a capital letter everywhere.
Help me troubleshoot the imports in decoder.py.
Profile validation starts with the frame as a whole. A usable profile needs a positive byte count plus at least one signal.
- Place the first part of load_profile() below REQUIRED_SIGNAL_KEYS.
- Keep the existing decode_frame() function below the new code.
def load_profile(profile_path):
path = Path(profile_path)
if not path.is_absolute():
path = BASE_DIR / path
with path.open(encoding="utf-8") as profile_file:
profile = json.load(profile_file)
frame_bytes = profile.get("frame_bytes")
if not isinstance(frame_bytes, int) or frame_bytes < 1:
raise ValueError("Profile frame_bytes must be a positive integer")
signals = profile.get("signals")
if not isinstance(signals, list) or not signals:
raise ValueError("Profile signals must be a non-empty list")
How does profile loading work?
- A relative profile filename is joined to BASE_DIR.
- The profile file is opened with UTF-8 encoding.
- The decoded JSON object is stored in profile.
- Invalid frame_bytes or signals values raise a clear ValueError.
Each signal also needs a complete rule. Its byte index must fit inside the declared frame.
- Continue load_profile() directly below the profile-level checks by adding this code:
for signal in signals:
missing_keys = REQUIRED_SIGNAL_KEYS - signal.keys()
if missing_keys:
missing = ", ".join(sorted(missing_keys))
raise ValueError(f"Signal is missing required keys: {missing}")
byte_index = signal["byte_index"]
if not isinstance(byte_index, int) or not 0 <= byte_index < frame_bytes:
raise ValueError("Signal byte_index must fit inside the frame")
if not isinstance(signal["scale"], (int, float)):
raise ValueError("Signal scale must be numeric")
if not isinstance(signal["offset"], (int, float)):
raise ValueError("Signal offset must be numeric")
return profile
What does signal validation protect?
- The required-key check catches incomplete signal definitions.
- The byte-index check prevents a signal from reading outside the frame.
- The scale check requires a numeric multiplier.
- The offset check requires a numeric adjustment.
- Save decoder.py.
- Run the current decoder by selecting Run Module from IDLE's Run menu.
- Enter 2D96 at the Hex frame: prompt.
Good progress. The familiar 90 km/h plus 75 % output confirms that the new validation code loads without breaking the working path.
Seeing an indentation problem?
Keep the signal loop inside load_profile() with four leading spaces. Keep the checks inside that loop with eight leading spaces.
Help me fix the indentation in load_profile().
The hard-coded decoder ties names to fixed payload positions. The generic version loops through the selected profile so the same function can follow any validated layout.
- Find the existing function that starts with def decode_frame(hex_frame): in decoder.py.
- Select that entire function through its closing return dictionary.
- Replace the selected function with this profile-driven version:
def decode_frame(hex_frame, profile):
try:
payload = bytes.fromhex(hex_frame)
except ValueError as error:
raise ValueError("Frame must contain hexadecimal byte pairs") from error
if len(payload) != profile["frame_bytes"]:
raise ValueError(
f"Frame must contain exactly {profile['frame_bytes']} bytes"
)
readings = {}
for signal in profile["signals"]:
raw_value = payload[signal["byte_index"]]
physical_value = raw_value * signal["scale"] + signal["offset"]
readings[signal["name"]] = {
"value": physical_value,
"unit": signal["unit"],
}
return readings
How does generic decoding work?
- The bytes.fromhex() conversion turns hexadecimal text into byte values.
- The frame-length check compares the payload with the selected profile.
- The loop reads each signal from its configured byte_index.
- The calculation applies the configured scale plus the configured offset.
Connect the prompts and verify both layouts
The decoder now expects a profile as its second argument. The main function needs to collect that filename before it asks for the frame.
- Find the existing main() function in decoder.py.
- Select the function from def main(): through its current print statement.
- Replace the selection with this profile-aware main function:
def main():
print("Profile-Driven Vehicle Telemetry Decoder")
print("Demo profiles are fictional and are not SAE definitions.")
profile_path = input("Profile filename: ").strip()
hex_frame = input("Hex frame: ").strip()
try:
profile = load_profile(profile_path)
readings = decode_frame(hex_frame, profile)
except (OSError, ValueError) as error:
print(f"Error: {error}")
return
print(format_readings(readings))
What does the main function control?
- The opening message keeps the fictional status of the demo data visible.
- The first prompt collects the profile filename.
- The second prompt collects the hexadecimal frame.
- The error handler turns file or validation failures into readable terminal output.
The blank licensed template provides a quick check for the new validation path. Its zero-byte definition should be rejected before decoding starts.
- Save decoder.py.
- Start the validation check by selecting Run Module from IDLE's Run menu.
- Enter licensed_profile.template.json at the Profile filename: prompt.
- Enter 2D96 at the Hex frame: prompt.
You see Error: Profile frame_bytes must be a positive integer. This confirms that unfinished profiles cannot reach the decoder.
Seeing a different profile error?
Check that licensed_profile.template.json contains 0 for frame_bytes. Confirm that the file sits beside decoder.py.
Help me troubleshoot the licensed profile validation check.
Before you test the completed decoder, do you expect both profile and frame pairs to produce the same physical readings?
- Run decoder.py by selecting Run Module from IDLE's Run menu.
- Enter demo_profile.json at the Profile filename: prompt.
- Enter 2D96 at the Hex frame: prompt.
You see demo_speed: 90 km/h followed by demo_tank_level: 75 %.
- Run decoder.py again by selecting Run Module from IDLE's Run menu.
- Enter alternate_profile.json at the Profile filename: prompt.
- Enter 962D at the Hex frame: prompt.
You see demo_tank_level: 75 % followed by demo_speed: 90 km/h. That hard-coding failure is fixed because each profile now controls its own byte layout.
Readings still look reversed?
Confirm that decode_frame() reads signal["byte_index"]. Remove any remaining references to payload[0] or payload[1] from that function.
Help me compare my decoder with the profile-driven version.
✔️ Awesome, I've got everything!
Your decoder now loads validated rules from profiles. Both fictional layouts produce the intended values.
ⓧ I'd like to double check the full code
import json
from pathlib import Path
BASE_DIR = Path(__file__).parent
REQUIRED_SIGNAL_KEYS = {"name", "byte_index", "scale", "offset", "unit"}
def load_profile(profile_path):
path = Path(profile_path)
if not path.is_absolute():
path = BASE_DIR / path
with path.open(encoding="utf-8") as profile_file:
profile = json.load(profile_file)
frame_bytes = profile.get("frame_bytes")
if not isinstance(frame_bytes, int) or frame_bytes < 1:
raise ValueError("Profile frame_bytes must be a positive integer")
signals = profile.get("signals")
if not isinstance(signals, list) or not signals:
raise ValueError("Profile signals must be a non-empty list")
for signal in signals:
missing_keys = REQUIRED_SIGNAL_KEYS - signal.keys()
if missing_keys:
missing = ", ".join(sorted(missing_keys))
raise ValueError(f"Signal is missing required keys: {missing}")
byte_index = signal["byte_index"]
if not isinstance(byte_index, int) or not 0 <= byte_index < frame_bytes:
raise ValueError("Signal byte_index must fit inside the frame")
if not isinstance(signal["scale"], (int, float)):
raise ValueError("Signal scale must be numeric")
if not isinstance(signal["offset"], (int, float)):
raise ValueError("Signal offset must be numeric")
return profile
def decode_frame(hex_frame, profile):
try:
payload = bytes.fromhex(hex_frame)
except ValueError as error:
raise ValueError("Frame must contain hexadecimal byte pairs") from error
if len(payload) != profile["frame_bytes"]:
raise ValueError(
f"Frame must contain exactly {profile['frame_bytes']} bytes"
)
readings = {}
for signal in profile["signals"]:
raw_value = payload[signal["byte_index"]]
physical_value = raw_value * signal["scale"] + signal["offset"]
readings[signal["name"]] = {
"value": physical_value,
"unit": signal["unit"],
}
return readings
def format_readings(readings):
lines = []
for name, reading in readings.items():
lines.append(f"{name}: {reading['value']:g} {reading['unit']}")
return "\n".join(lines)
def main():
print("Profile-Driven Vehicle Telemetry Decoder")
print("Demo profiles are fictional and are not SAE definitions.")
profile_path = input("Profile filename: ").strip()
hex_frame = input("Hex frame: ").strip()
try:
profile = load_profile(profile_path)
readings = decode_frame(hex_frame, profile)
except (OSError, ValueError) as error:
print(f"Error: {error}")
return
print(format_readings(readings))
if __name__ == "__main__":
main()
{
"profile_name": "NextWork Fictional Telemetry Demo",
"frame_bytes": 2,
"signals": [
{
"name": "demo_speed",
"byte_index": 0,
"scale": 2,
"offset": 0,
"unit": "km/h"
},
{
"name": "demo_tank_level",
"byte_index": 1,
"scale": 0.5,
"offset": 0,
"unit": "%"
}
]
}
{
"profile_name": "NextWork Fictional Alternate Layout",
"frame_bytes": 2,
"signals": [
{
"name": "demo_tank_level",
"byte_index": 0,
"scale": 0.5,
"offset": 0,
"unit": "%"
},
{
"name": "demo_speed",
"byte_index": 1,
"scale": 2,
"offset": 0,
"unit": "km/h"
}
]
}
{
"profile_name": "Licensed Definitions Template",
"source_revision": "Enter the revision from your licensed source",
"frame_bytes": 0,
"signals": []
}
Your decoder now adapts to different fictional layouts through profile data. Next up, you will protect those results with automated tests.
Protect the Decoder with Tests
Both JSON profiles now produce the same physical readings from different byte layouts. Your reusable decoder has passed its manual checks.
Manual output can look convincing even when a layout is wrong. Automated tests turn each expected reading into a repeatable check.
In this step, get ready to:
- Create test_decoder.py with Python's standard-library testing framework.
- Verify the expected readings for both fictional profile layouts.
- Prove that malformed frames raise the expected exceptions.
Create the test foundation
Each test needs access to the decoder functions plus a fresh copy of both profiles. The setUp method prepares those profile objects before each test runs.
- Return to IDLE from earlier.
- Choose New File from IDLE's file menu.
- Start test_decoder.py by copying the code below into the new editor window:
import unittest
from decoder import decode_frame, load_profile
class DecoderTests(unittest.TestCase):
def setUp(self):
self.demo_profile = load_profile("demo_profile.json")
self.alternate_profile = load_profile("alternate_profile.json")
What does this test foundation do?
- The unittest import provides the test runner plus its assertions.
- The decoder import lets the tests call load_profile plus decode_frame directly.
- The setUp method loads both fictional profiles before every test method.
- Choose Save As… from IDLE's file menu.
- Select the telemetry_decoder folder in the save dialog.
- Enter test_decoder.py as the filename.
- Confirm the save in the dialog.
You'll see test_decoder.py in the editor title bar. The test file now sits beside the decoder plus both profiles it loads.
Can't find the saved test file?
Check that the save dialog points to the same telemetry_decoder folder that contains decoder.py.
Use help me place test_decoder.py beside decoder.py in IDLE if the files landed in different folders.
Verify both profile layouts
An equality test compares a decoded value with the physical value defined by the fictional profile. The first test locks in the readings produced by 2D96.
- Place the cursor after the line that loads alternate_profile.json.
- Add the demo-profile test plus the test runner by copying this code below that line:
def test_demo_profile(self):
readings = decode_frame("2D96", self.demo_profile)
self.assertEqual(readings["demo_speed"]["value"], 90)
self.assertEqual(readings["demo_tank_level"]["value"], 75)
if __name__ == "__main__":
unittest.main()
What does this demo test prove?
- The test decodes 2D96 with the demo profile loaded during setup.
- The two equality assertions require speed to equal 90 plus tank level to equal 75.
- The final guard starts the test runner when IDLE runs this file as a module.
- Save test_decoder.py.
- Choose Run Module from IDLE's run menu.
The Shell reports that one test ran. The result ends with OK.
Does the demo test fail?
Check that the test frame is 2D96. Confirm that the expected values are 90 plus 75.
Use help me compare the expected and actual demo-profile readings if the Shell shows a failure.
The alternate layout reverses the two source bytes. Its test proves that profile metadata preserves the same physical readings.
- Return to the test_decoder.py editor window.
- Find the line that begins with if __name__.
- Insert the alternate-profile test immediately above that line by copying this code:
def test_alternate_profile(self):
readings = decode_frame("962D", self.alternate_profile)
self.assertEqual(readings["demo_speed"]["value"], 90)
self.assertEqual(readings["demo_tank_level"]["value"], 75)
What does the alternate test prove?
- The test decodes 962D with the alternate profile.
- Its equality assertions require the reversed layout to produce the same speed plus tank level.
- Save test_decoder.py.
- Choose Run Module from IDLE's run menu.
The Shell reports that two tests ran. The result ends with OK.
Great. Both fictional layouts now have repeatable proof that they decode to the same physical values.
Does the alternate test fail?
Check that this test uses 962D with self.alternate_profile. A demo-profile reference would apply the wrong byte positions.
Use help me diagnose my alternate-profile equality test if the failure remains.
Reject malformed frames
Correct readings cover the decoder's successful path. Exceptions protect it when a frame contains invalid hexadecimal text or the wrong number of bytes.
- Return to the test_decoder.py editor window.
- Find the line that begins with if __name__.
- Insert the non-hexadecimal input test immediately above that line by copying this code:
def test_rejects_non_hexadecimal_input(self):
with self.assertRaisesRegex(ValueError, "hexadecimal byte pairs"):
decode_frame("ZZ", self.demo_profile)
What does this error test prove?
- The frame ZZ cannot be decoded as hexadecimal bytes.
- The assertRaisesRegex check requires a ValueError whose message includes hexadecimal byte pairs.
- Save test_decoder.py.
- Choose Run Module from IDLE's run menu.
The Shell reports that three tests ran. The result still ends with OK.
Does the hexadecimal test fail?
Confirm that the test passes ZZ to decode_frame. Check that the expected message fragment matches hexadecimal byte pairs.
Use help me diagnose the non-hexadecimal exception test if the assertion still fails.
The demo profile expects exactly two bytes. A one-byte frame must be rejected before the decoder reads its signals.
- Place the cursor after the test_rejects_non_hexadecimal_input method.
- Add the wrong-length test by copying this code below that method:
def test_rejects_wrong_frame_length(self):
with self.assertRaisesRegex(ValueError, "exactly 2 bytes"):
decode_frame("2D", self.demo_profile)
What does the length test prove?
- The frame 2D contains one byte.
- The demo profile requires two bytes.
- The assertion checks for a ValueError whose message includes exactly 2 bytes.
Before you run the complete suite, do you expect every valid case plus every malformed case to pass?
- Save test_decoder.py.
- Choose Run Module from IDLE's run menu.
The Shell reports Ran 4 tests followed by OK.
That's the decoder protected. Both layouts produce the expected readings while malformed frames stop with clear validation errors.
Does the full suite show a failure?
Read the failing test name in the Shell. Compare its expected value with the actual value before changing the implementation.
Use help me interpret my unittest failure output if the output is unclear.
✔️ Awesome, I've got everything!
Your complete test_decoder.py file is saved. All four tests pass.
ⓧ I'd like to double check the full code
import unittest
from decoder import decode_frame, load_profile
class DecoderTests(unittest.TestCase):
def setUp(self):
self.demo_profile = load_profile("demo_profile.json")
self.alternate_profile = load_profile("alternate_profile.json")
def test_demo_profile(self):
readings = decode_frame("2D96", self.demo_profile)
self.assertEqual(readings["demo_speed"]["value"], 90)
self.assertEqual(readings["demo_tank_level"]["value"], 75)
def test_alternate_profile(self):
readings = decode_frame("962D", self.alternate_profile)
self.assertEqual(readings["demo_speed"]["value"], 90)
self.assertEqual(readings["demo_tank_level"]["value"], 75)
def test_rejects_non_hexadecimal_input(self):
with self.assertRaisesRegex(ValueError, "hexadecimal byte pairs"):
decode_frame("ZZ", self.demo_profile)
def test_rejects_wrong_frame_length(self):
with self.assertRaisesRegex(ValueError, "exactly 2 bytes"):
decode_frame("2D", self.demo_profile)
if __name__ == "__main__":
unittest.main()
How to use this reference
Compare this file with your saved version from top to bottom. Check the indentation of every method inside DecoderTests.
Secret mission
Add a Third Signal Without Editing Python
Your decoder already supports new layouts through configuration. Extend it with a fictional temperature signal through JSON, then prove the unchanged Python decoder handles it with a fifth automated test.
Clean Up Your Resources
Clean Up Your Resources
This local project creates no ongoing service costs. Use the options below to keep its files, pause your editing session, or delete the project.
Resources you used:
- telemetry_decoder folder containing decoder.py, demo_profile.json, alternate_profile.json, licensed_profile.template.json, test_decoder.py, and extended_profile.json.
Keep everything running
No action is needed. Choose this option if you want to decode more fictional frames or design more profiles.
- Keep the telemetry_decoder folder in its current saved location.
- Return to decoder.py in IDLE whenever you want to decode another fictional frame.
- Run test_decoder.py after future profile changes to confirm the five tests still pass.
Pause - I'll come back to this later
Your decoder has no background service to stop. A paused project is the saved telemetry_decoder folder with IDLE closed.
- Save each open project file in IDLE.
- Close every IDLE window.
- Leave the telemetry_decoder folder in its current saved location.
Delete - I don't want to use this again
This option removes every file in the telemetry_decoder folder from your Mac.
Deleting the folder is permanent. Your Python tools remain available for future projects.
- Close every IDLE window.
- Use Finder to locate the telemetry_decoder folder where you saved it.
- Delete the entire telemetry_decoder folder through Finder.
- Permanently clear deleted files from your Mac.
- Search for telemetry_decoder in Finder.
You should see no matching folder. This confirms that all project files are gone.
Nice Work!
Nice Work!
You did it! Your fictional profile-driven telemetry decoder now handles changing byte layouts through reusable profiles. Five automated tests prove the decoder handles valid profiles while rejecting malformed frames.
What you learned:
- Built a reusable profile-driven decoder in Python that turns hexadecimal frames into readable fictional telemetry values.
- Separated signal metadata into reusable JSON profiles. This keeps byte positions, scales, offsets, and units outside the decoding logic.
- Created a five-test automated safety net that checks two byte layouts, malformed input, incorrect frame length, and the extended profile.
- Secret Mission: Added a fictional temperature signal through `extended_profile.json`. You proved the decoder could produce `demo_temperature: 60 °C` without changing `decoder.py`.
Ready to quiz yourself?