Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
36 commits
Select commit Hold shift + click to select a range
df7d073
Add .gitattributes to ignore specific files and folders
Robotmad May 3, 2026
48bf981
Upgrade setup-python action to version 5
Robotmad May 3, 2026
370c598
Fix python-version formatting in release.yml
Robotmad May 3, 2026
e5ec3ee
eliminate pylance issues
Robotmad May 3, 2026
c5e3554
allow .mpy files into repo
Robotmad May 3, 2026
72383a7
export-ignore entries in .gitattributes for release
Robotmad May 3, 2026
4756c45
mpy files for release
Robotmad May 3, 2026
cc04f40
Fix typo in .gitattributes for typings export-ignore
Robotmad May 3, 2026
cf3b278
Add .gitattributes to export-ignore list in .gitattributes
Robotmad May 3, 2026
bf1e315
added missing serialise_mgr.mpy
Robotmad May 3, 2026
56958fd
Bump version number to 0.3
Robotmad May 3, 2026
46241ed
Merge pull request #1 from TeamRobotmad/release-v0.3
Robotmad May 3, 2026
4b47e8e
Update .gitattributes to change export-ignore settings and adjust bin…
Robotmad May 3, 2026
6c5499b
Bump version number to 0.4
Robotmad May 3, 2026
564c3ec
Update app version to 0.4
Robotmad May 3, 2026
3b84464
Merge branch 'main' of https://github.com/TeamRobotmad/HexManager
Robotmad May 3, 2026
40473bc
v0.4 to be consistent with versions
Robotmad May 3, 2026
677fb29
Add 'serialise_mgr' to runtime modules list
Robotmad May 3, 2026
6b5ff01
Refactor minimum BadgeOS version check to use a constant for better m…
lincoltd7 May 9, 2026
68f1413
Use HexDrive2 for V2 EEPROM deployment
lincoltd7 May 9, 2026
bc9c7fe
Vendor HexCurrent for EEPROM deployment
lincoltd7 May 10, 2026
ec894ab
Bump vendored HexDrive2 submodule
lincoltd7 May 10, 2026
772035f
Bump vendored HexCurrent submodule
lincoltd7 May 10, 2026
36b0e01
Refresh compiled EEPROM artifacts
lincoltd7 May 10, 2026
4ce2d0b
Auto-detect blank EEPROM geometry
lincoltd7 May 10, 2026
bf865a8
Improve warning handling and EEPROM scan UX
lincoltd7 May 10, 2026
6b9d7b9
Separate display and header friendly names
lincoltd7 May 10, 2026
a62b452
Potential fix for pull request finding
Robotmad May 10, 2026
706d5cd
fix: refresh release file set after mpy generation
Copilot May 10, 2026
10e99f9
fix: init submodules in release workflow and remove debug prints
Copilot May 10, 2026
bd15dab
Potential fix for pull request finding
Robotmad May 10, 2026
a795bbc
Simplify submodule management in tests workflow
Robotmad May 10, 2026
f63d815
Merge branch 'main' into eeprom-geometry-autodetect
Robotmad May 10, 2026
0381d16
Update tests.yml to include badge repo checkout step
Robotmad May 10, 2026
e4f78e3
Change Python setup to use version 3.11
Robotmad May 10, 2026
1e96d15
Merge branch 'main' into eeprom-geometry-autodetect
Robotmad May 10, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions .gitattributes
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
.gitignore export-ignore
.gitattributes export-ignore
download.bat export-ignore
download.sh export-ignore
pyproject.toml export-ignore
README.md export-ignore
.github/ export-ignore
.vscode/ export-ignore
dev/ export-ignore
tests/ export-ignore
typings/ export-ignore
*.py export-ignore
*.mpy binary -diff
6 changes: 4 additions & 2 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -8,10 +8,12 @@ jobs:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
submodules: recursive
- name: Set up Python
uses: actions/setup-python@v3
uses: actions/setup-python@v5
with:
python-version: 3.10
python-version: "3.10"
- name: Run release setup script
run: |
python3 ./dev/build_release.py -f
Comment thread
Robotmad marked this conversation as resolved.
2 changes: 0 additions & 2 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -1,9 +1,7 @@
*.pyc
*.pyo
*.mpy
HexManager.code-workspace
.deploy_state/test_device_download_state.json
.editorconfig
.venv/
.venv-wsl*/

6 changes: 6 additions & 0 deletions .gitmodules
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
[submodule "vendor/HexDrive2"]
path = vendor/HexDrive2
url = https://github.com/TeamRobotmad/HexDrive2.git
[submodule "vendor/HexCurrent"]
path = vendor/HexCurrent
url = https://github.com/TeamRobotmad/HexCurrent.git
Binary file added EEPROM/caffeine.mpy
Binary file not shown.
Binary file added EEPROM/gps.mpy
Binary file not shown.
Binary file added EEPROM/hexcurrent.mpy
Binary file not shown.
Binary file added EEPROM/hexdrive.mpy
Binary file not shown.
5 changes: 4 additions & 1 deletion EEPROM/hexdrive.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,9 @@

import app

# Define the minimum BadgeOS version required to run this app (e.g. if we need features that are only available in a certain version of BadgeOS)
_MIN_BADGEOS_VERSION = [1, 9, 0] # v1.9.0 is required to be able to read the EEPROM with 16-bit addressing

# HexDrive Hexpansion constants
# Hardware defintions:
_ENABLE_PIN = 0 # First LS pin used to enable the SMPSU
Expand Down Expand Up @@ -92,7 +95,7 @@ def __init__(self, config: HexpansionConfig | None = None):
ver = self._parse_version(ota.get_version())
#print(f"D:S/W {ver}")
# e.g. v1.9.0-beta.1
if ver >= [1, 9, 0]:
if ver >= _MIN_BADGEOS_VERSION:
# we need v1.9.0+ to be able to read the EEPROM with 16-bit addressing, so if we are running on an older version then we cannot continue
pass
else:
Expand Down
Binary file added EEPROM/hexdrive2.mpy
Binary file not shown.
3 changes: 2 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -55,7 +55,8 @@ HexManager reads hexpansion type definitions from a JSON file named **`hexpansio
| Field | Required | Default | Description |
|---|---|---|---|
| `pid` | ✅ | – | Product ID (0–65535). Must be unique within the same VID. |
| `name` | ✅ | – | Display name shown on screen ≤ 9 chars. |
| `name` | ✅ | – | Display name shown in HexManager. |
| `friendly_name` | No | `name` | Short name written into the EEPROM header and used by BadgeOS insertion notifications. Must fit within 9 chars. |
| `vid` | No | "0xCAFE" | Vendor ID. |
| `eeprom_total_size` | No | 8192 | EEPROM size in **bytes** (e.g. 2048, 8192, 32768, 65536). |
| `eeprom_page_size` | No | 32 | Write page size in **bytes** – check your EEPROM datasheet (e.g. 16, 32, 64, 128). |
Expand Down
Binary file added app.mpy
Binary file not shown.
117 changes: 108 additions & 9 deletions app.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,11 +15,14 @@
RequestStopAppEvent)
import app

APP_VERSION = "0.2" # HexManager App Version Number
APP_VERSION = "0.4" # HexManager App Version Number


_HEXPANSIONS_JSON = "hexpansions.json" # Name of the hexpansion type definitions file
_SETTINGS_NAME_PREFIX = "hexmanager." # Prefix for settings keys in EEPROM
_MESSAGE_MAX_LINES = 5
_MESSAGE_WRAP_COLUMNS = 18
_IMPORT_ERRORS: dict[str, str] = {}

# Screen positioning constant for scroll mode display
H_START = -63
Expand Down Expand Up @@ -68,10 +71,13 @@ def _try_import(module_name, *attr_names):
try:
__import__(full_name)
mod = sys.modules[full_name]
_IMPORT_ERRORS.pop(module_name, None)
return tuple(getattr(mod, n) for n in attr_names)
except ImportError as e:
_IMPORT_ERRORS[module_name] = str(e)
print(f"Warning: {module_name} module not found ({e})")
except Exception as e: # pylint: disable=broad-except
_IMPORT_ERRORS[module_name] = f"{type(e).__name__}: {e}"
print(f"Error importing {module_name} module ({e})")
return nones

Expand All @@ -80,6 +86,71 @@ def _try_import(module_name, *attr_names):
SettingsMgr, MySetting = _try_import('settings_mgr', 'SettingsMgr', 'MySetting')


def _wrap_message_line(line, max_columns=_MESSAGE_WRAP_COLUMNS):
text = str(line)
if not text:
return [""]

def _split_long_word(word):
return [word[i:i + max_columns] for i in range(0, len(word), max_columns)] or [""]

wrapped = []
current = ""
for word in text.split():
chunks = _split_long_word(word)
for idx, chunk in enumerate(chunks):
if idx > 0 and current:
wrapped.append(current)
current = ""
if not current:
current = chunk
elif len(current) + 1 + len(chunk) <= max_columns:
current = f"{current} {chunk}"
else:
wrapped.append(current)
current = chunk
if current or not wrapped:
wrapped.append(current)
return wrapped


def _paginate_message(message, colours, max_lines=_MESSAGE_MAX_LINES):
pages = []
page_lines = []
page_colours = []
source_lines = list(message) if message else [""]
source_colours = list(colours) if colours else []

for idx, line in enumerate(source_lines):
colour = source_colours[idx] if idx < len(source_colours) else (1, 1, 1)
for wrapped_line in _wrap_message_line(line):
if len(page_lines) >= max_lines:
pages.append((page_lines, page_colours))
page_lines = []
page_colours = []
page_lines.append(wrapped_line)
page_colours.append(colour)

if page_lines or not pages:
pages.append((page_lines, page_colours))

return pages


def _startup_warning_message(warning):
title = _HEXPANSIONS_JSON
body = warning
if warning.startswith("hexpansion_mgr import failed:"):
detail = warning.split(":", 1)[1].strip()
title = "hexpansion_mgr"
body = "import failed: " + detail
elif warning.startswith(_HEXPANSIONS_JSON):
body = warning[len(_HEXPANSIONS_JSON):].strip(": ")
else:
title = "HexManager"
return [title, "warning:", body], [(1, 0.6, 0)] * 3


def _load_hexpansion_types(app_file_path, json_path=None):
"""Load hexpansion type definitions from hexpansions.json next to this app file.

Expand All @@ -100,8 +171,9 @@ def _load_hexpansion_types(app_file_path, json_path=None):
types_list = []
warnings = []
if HexpansionType is None:
warnings.append("hexpansion type support unavailable")
print("H:Warning: hexpansion type support unavailable (HexpansionType import failed)")
reason = _IMPORT_ERRORS.get("hexpansion_mgr", "HexpansionType import failed")
warnings.append(f"hexpansion_mgr import failed: {reason}")
print(f"H:Warning: hexpansion_mgr import failed ({reason})")
return types_list, warnings
if json_path is None:
last_slash = max(app_file_path.rfind("/"), app_file_path.rfind("\\"))
Expand Down Expand Up @@ -139,6 +211,7 @@ def _load_hexpansion_types(app_file_path, json_path=None):
types_list.append(HexpansionType(
pid=h["pid"],
name=str(h["name"]),
friendly_name=str(h["friendly_name"]) if h.get("friendly_name") is not None else str(h["name"]),
vid=h.get("vid", 0xCAFE),
eeprom_total_size=h.get("eeprom_total_size", 8192),
eeprom_page_size=h.get("eeprom_page_size", 32),
Expand Down Expand Up @@ -175,6 +248,8 @@ def __init__(self):
self.message: list = []
self.message_colours: list = []
self.message_type: str | None = None
self._message_pages: list[tuple[list[str], list[tuple[float, float, float]]]] = []
self._message_page_index: int = 0
self.current_menu: str | None = None
self.menu: Menu | None = None
self.scroll_mode_enabled: bool = False # Whether pressing the "C" button can toggle scroll mode on/off, which allows the user to scroll through lines on the display.
Expand Down Expand Up @@ -382,10 +457,9 @@ def _update_main_application(self, delta: int):
# Show any startup warnings once (e.g. hexpansions.json not found or parse error)
if self._startup_warnings and self.current_state != STATE_MESSAGE:
w = self._startup_warnings[0]
if w.startswith(_HEXPANSIONS_JSON):
w = w[len(_HEXPANSIONS_JSON):].strip(": ")
self._startup_warning_active = True
self.show_message([_HEXPANSIONS_JSON, "warning:", w], [(1, 0.6, 0)] * 3, msg_type="warning")
msg_content, msg_colours = _startup_warning_message(w)
self.show_message(msg_content, msg_colours, msg_type="warning")
return
if self.current_state == STATE_MENU:
if self.current_menu is None:
Expand Down Expand Up @@ -430,6 +504,18 @@ def _update_main_application(self, delta: int):


def _update_state_message(self, delta: int): # pylint: disable=unused-argument
if len(self._message_pages) > 1 and self.button_states.get(BUTTON_TYPES["UP"]):
self.button_states.clear()
if self._message_page_index > 0:
self._set_message_page(self._message_page_index - 1)
self.refresh = True
return
if len(self._message_pages) > 1 and self.button_states.get(BUTTON_TYPES["DOWN"]):
self.button_states.clear()
if self._message_page_index < len(self._message_pages) - 1:
self._set_message_page(self._message_page_index + 1)
self.refresh = True
return
if self.button_states.get(BUTTON_TYPES["CONFIRM"]):
if self.message_type == "reboop":
self.button_states.clear()
Expand All @@ -456,6 +542,8 @@ def _update_state_message(self, delta: int): # pylint: disable=unused-argum
self.message = []
self.message_colours = []
self.message_type = None
self._message_pages = []
self._message_page_index = 0


def scroll_mode_enable(self, enable: bool):
Expand Down Expand Up @@ -514,7 +602,9 @@ def draw(self, ctx):
self.message_colours = [(1,0,0)]*len(self.message)
self.draw_message(ctx, self.message, self.message_colours, label_font_size)
if self.message_type is None or self.message_type == "warning" or self.message_type == "hexpansion" or self.message_type == "serialise":
button_labels(ctx, confirm_label="OK", cancel_label="Exit")
up_label = "Prev" if self._message_page_index > 0 else ""
down_label = "Next" if self._message_page_index < len(self._message_pages) - 1 else ""
button_labels(ctx, confirm_label="OK", cancel_label="Exit", up_label=up_label, down_label=down_label)
else:
# Delegate to functional area managers via dispatch table
if self.current_state in self._state_draw_dispatch:
Expand Down Expand Up @@ -561,12 +651,21 @@ def return_to_menu(self, menu_name: str | None = None):
self.refresh = True


def _set_message_page(self, index: int):
self._message_page_index = index
if not self._message_pages:
self.message = []
self.message_colours = []
return
self.message, self.message_colours = self._message_pages[index]


def show_message(self, msg_content, msg_colours, msg_type = None):
"""Utility function to set the current state to the message display, and populate the message content and colours. The message_type can be used to indicate whether this is an 'error' (red) or 'warning' (green) message, which can affect both the display and the behaviour when the user acknowledges the message."""
if self.logging:
print(f"Showing message: '{msg_content}' with type {msg_type}")
self.message = msg_content
self.message_colours = msg_colours
self._message_pages = _paginate_message(msg_content, msg_colours)
self._set_message_page(0)
self.message_type = msg_type
self.current_state = STATE_MESSAGE
self.refresh = True
Expand Down
40 changes: 36 additions & 4 deletions dev/build_release.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,19 +2,32 @@
import os
import subprocess
import sys
from dataclasses import dataclass
from pathlib import Path

import mpy_cross


@dataclass(frozen=True)
class ModuleSpec:
source: Path
artifact: Path

RUNTIME_MODULES = {
"app",
"EEPROM/hexdrive",
"EEPROM/gps",
"EEPROM/caffeine",
"settings_mgr",
"serialise_mgr",
"hexpansion_mgr",
}

EXTERNAL_MODULES = (
ModuleSpec(Path("vendor/HexDrive2/hexdrive2.py"), Path("EEPROM/hexdrive2.mpy")),
ModuleSpec(Path("vendor/HexCurrent/hexcurrent.py"), Path("EEPROM/hexcurrent.mpy")),
)

files_to_mpy = {Path(f"{module}.py") for module in RUNTIME_MODULES}

files_to_keep = {
Expand All @@ -24,19 +37,34 @@
Path("hexpansions.json"),
}
files_to_keep.update({Path(f"{module}.mpy") for module in RUNTIME_MODULES})
files_to_keep.update({spec.artifact for spec in EXTERNAL_MODULES})

IGNORED_SOURCE_DIRS = (Path("vendor/HexDrive2"), Path("vendor/HexCurrent"))

def _construct_filepaths(dirname, filenames):
return [Path(dirname, filename) for filename in filenames]

def _normalise_parts(path: Path) -> tuple[str, ...]:
return tuple(part for part in path.parts if part not in (".", ""))

def _is_ignored_dir(dirname: str) -> bool:
parts = _normalise_parts(Path(dirname))
if ".git" in parts:
return True
for ignored_dir in IGNORED_SOURCE_DIRS:
ignored_parts = _normalise_parts(ignored_dir)
if parts[: len(ignored_parts)] == ignored_parts:
return True
return False

def find_files(top_level_dir):
walkerator = iter(os.walk(top_level_dir))
dirname, _, filenames = next(walkerator)

all_files = _construct_filepaths(dirname, filenames)

for dirname, _, filenames in walkerator:
# if dirname not in dirs_to_keep:
if dirname != "./.git" and ".git/" not in dirname:
if not _is_ignored_dir(dirname):
all_files.extend(_construct_filepaths(dirname, filenames))

return all_files
Expand All @@ -49,12 +77,16 @@ def find_files(top_level_dir):
parser.add_argument("-f", "--force", action="store_true", help="Skip confirmation prompt before file removal.")
options = parser.parse_args()
force_mode = options.force
found_files = set(find_files("."))

for file in files_to_mpy:
print(f"Mpy-ing file: {file}")
mpy_cross.run(file, "-v")

for spec in EXTERNAL_MODULES:
print(f"Mpy-ing file: {spec.source} -> {spec.artifact}")
spec.artifact.parent.mkdir(parents=True, exist_ok=True)
mpy_cross.run(str(spec.source), "-v", "-o", str(spec.artifact))

found_files = set(find_files("."))
if not files_to_keep.issubset(found_files):
raise FileNotFoundError(f"Some of {files_to_keep} are not found so assuming wrong directory. "
"Please run this script from HexManager dir.")
Expand Down
2 changes: 2 additions & 0 deletions dev/download_to_device.py
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,8 @@ class ModuleSpec:
ModuleSpec(Path("hexpansion_mgr.py"), Path("hexpansion_mgr.mpy")),
ModuleSpec(Path("serialise_mgr.py"), Path("serialise_mgr.mpy")),
ModuleSpec(Path("EEPROM/hexdrive.py"), Path("EEPROM/hexdrive.mpy")),
ModuleSpec(Path("vendor/HexDrive2/hexdrive2.py"), Path("EEPROM/hexdrive2.mpy")),
ModuleSpec(Path("vendor/HexCurrent/hexcurrent.py"), Path("EEPROM/hexcurrent.mpy")),
ModuleSpec(Path("EEPROM/gps.py"), Path("EEPROM/gps.mpy")),
ModuleSpec(Path("EEPROM/caffeine.py"), Path("EEPROM/caffeine.mpy"))
)
Expand Down
Binary file added hexpansion_mgr.mpy
Binary file not shown.
Loading
Loading