diff --git a/.clang-format b/.clang-format new file mode 100644 index 0000000..0d57477 --- /dev/null +++ b/.clang-format @@ -0,0 +1,198 @@ + +# Tailored toward The Norm Version 3 formatting (indent, braces, spacing, +# line length, etc.) as closely as ClangFormat's option set allows. +# +# ft_ / FT_ prefix naming rules from The Norm are intentionally excluded; +# they do not apply to this project. +# Rules ClangFormat cannot express still need manual / Norminette checks +# (naming prefixes other than ft_, return parentheses, tab between return +# type and function name, function-size limits, forbidden constructs, etc.). +--- +AccessModifierOffset: -2 +AlignAfterOpenBracket: Align +AlignArrayOfStructures: None +AlignConsecutiveAssignments: None +AlignConsecutiveBitFields: None +AlignConsecutiveDeclarations: Consecutive +AlignConsecutiveMacros: None +AlignEscapedNewlines: DontAlign +AlignOperands: Align +AlignTrailingComments: true +AllowAllArgumentsOnNextLine: true +AllowAllConstructorInitializersOnNextLine: true +AllowAllParametersOfDeclarationOnNextLine: true +AllowShortBlocksOnASingleLine: Never +AllowShortCaseLabelsOnASingleLine: true +AllowShortEnumsOnASingleLine: false +AllowShortFunctionsOnASingleLine: None +AllowShortIfStatementsOnASingleLine: AllIfsAndElse +AllowShortLambdasOnASingleLine: Empty +AllowShortLoopsOnASingleLine: true +AlwaysBreakAfterDefinitionReturnType: None +AlwaysBreakAfterReturnType: None +AlwaysBreakBeforeMultilineStrings: false +AlwaysBreakTemplateDeclarations: No +AttributeMacros: + - __capability +BasedOnStyle: LLVM +BinPackArguments: true +BinPackParameters: true +BitFieldColonSpacing: Both +BraceWrapping: + AfterCaseLabel: true + AfterClass: true + AfterControlStatement: Always + AfterEnum: true + AfterFunction: true + AfterNamespace: true + AfterObjCDeclaration: true + AfterStruct: true + AfterUnion: true + AfterExternBlock: true + BeforeCatch: true + BeforeElse: true + BeforeLambdaBody: false + BeforeWhile: true + IndentBraces: false + SplitEmptyFunction: true + SplitEmptyRecord: true + SplitEmptyNamespace: true +BreakAfterJavaFieldAnnotations: false +BreakBeforeBinaryOperators: All +BreakBeforeBraces: Allman +BreakBeforeConceptDeclarations: false +BreakBeforeInheritanceComma: false +BreakBeforeTernaryOperators: true +BreakConstructorInitializers: BeforeColon +BreakConstructorInitializersBeforeComma: false +BreakInheritanceList: BeforeColon +BreakStringLiterals: false +ColumnLimit: 80 +CommentPragmas: '' +CompactNamespaces: false +ConstructorInitializerAllOnOneLineOrOnePerLine: false +ConstructorInitializerIndentWidth: 4 +ContinuationIndentWidth: 4 +Cpp11BracedListStyle: false +DeriveLineEnding: true +DerivePointerAlignment: false +DisableFormat: false +EmptyLineAfterAccessModifier: Leave +EmptyLineBeforeAccessModifier: Leave +ExperimentalAutoDetectBinPacking: false +FixNamespaceComments: false +ForEachMacros: + - foreach + - Q_FOREACH + - BOOST_FOREACH +IfMacros: + - KJ_IF_MAYBE +IncludeBlocks: Preserve +IncludeCategories: + - Regex: '^"(llvm|llvm-c|clang|clang-c)/' + Priority: 2 + SortPriority: 0 + CaseSensitive: false + - Regex: '^(<|"(gtest|gmock|isl|json)/)' + Priority: 3 + SortPriority: 0 + CaseSensitive: false + - Regex: '.*' + Priority: 1 + SortPriority: 0 + CaseSensitive: false +IncludeIsMainRegex: '' +IncludeIsMainSourceRegex: '' +IndentAccessModifiers: false +IndentCaseBlocks: true +IndentCaseLabels: true +IndentExternBlock: Indent +IndentGotoLabels: false +IndentPPDirectives: AfterHash +IndentRequires: true +IndentWidth: 4 +IndentWrappedFunctionNames: false +InsertTrailingCommas: None +JavaScriptQuotes: Leave +JavaScriptWrapImports: true +KeepEmptyLinesAtTheStartOfBlocks: true +LambdaBodyIndentation: Signature +Language: Cpp +MacroBlockBegin: '' +MacroBlockEnd: '' +MaxEmptyLinesToKeep: 1 +NamespaceIndentation: None +ObjCBinPackProtocolList: Auto +ObjCBlockIndentWidth: 4 +ObjCBreakBeforeNestedBlockParam: true +ObjCSpaceAfterProperty: false +ObjCSpaceBeforeProtocolList: true +PPIndentWidth: 1 +PackConstructorInitializers: BinPack +PenaltyBreakAssignment: 1 +PenaltyBreakBeforeFirstCallParameter: 1 +PenaltyBreakComment: 1 +PenaltyBreakFirstLessLess: 1 +PenaltyBreakOpenParenthesis: 1 +PenaltyBreakString: 1 +PenaltyBreakTemplateDeclaration: 1 +PenaltyExcessCharacter: 1 +PenaltyIndentedWhitespace: 1 +PenaltyReturnTypeOnItsOwnLine: 1 +PointerAlignment: Right +QualifierAlignment: Leave +ReferenceAlignment: Pointer +ReflowComments: false +RemoveBracesLLVM: false +SeparateDefinitionBlocks: Leave +ShortNamespaceLines: 0 +SortIncludes: Never +SortJavaStaticImport: Before +SortUsingDeclarations: false +SpaceAfterCStyleCast: false +SpaceAfterLogicalNot: false +SpaceAfterTemplateKeyword: false +SpaceAroundPointerQualifiers: Default +SpaceBeforeAssignmentOperators: true +SpaceBeforeCaseColon: false +SpaceBeforeCpp11BracedList: false +SpaceBeforeCtorInitializerColon: true +SpaceBeforeInheritanceColon: true +SpaceBeforeParens: ControlStatements +SpaceBeforeParensOptions: + AfterControlStatements: true + AfterForeachMacros: true + AfterFunctionDefinitionName: false + AfterFunctionDeclarationName: false + AfterIfMacros: true + AfterOverloadedOperator: false + BeforeNonEmptyParentheses: false +SpaceBeforeRangeBasedForLoopColon: true +SpaceBeforeSquareBrackets: false +SpaceInEmptyBlock: false +SpaceInEmptyParentheses: false +SpacesBeforeTrailingComments: 2 +SpacesInAngles: Leave +SpacesInCStyleCastParentheses: false +SpacesInConditionalStatement: false +SpacesInContainerLiterals: false +SpacesInLineCommentPrefix: + Minimum: 0 + Maximum: -1 +SpacesInParentheses: false +SpacesInSquareBrackets: false +Standard: Auto +StatementAttributeLikeMacros: + - Q_EMIT +StatementMacros: + - Q_UNUSED + - QT_REQUIRE_VERSION +TabWidth: 4 +UseCRLF: false +UseTab: Always +WhitespaceSensitiveMacros: + - STRINGIZE + - PP_STRINGIZE + - BOOST_PP_STRINGIZE + - NS_SWIFT_NAME + - CF_SWIFT_NAME diff --git a/.gitignore b/.gitignore index e6b83ef..6e09f85 100644 --- a/.gitignore +++ b/.gitignore @@ -57,6 +57,10 @@ release-info.json *.idb *.pdb +# Python bytecode +__pycache__/ +*.pyc + # Kernel Module Compile Results *.mod* *.cmd diff --git a/Makefile b/Makefile index db1c040..8274b3f 100644 --- a/Makefile +++ b/Makefile @@ -1,354 +1,64 @@ - ################################################################## -# Firmware Release Manager for the 42 Smart Cluster Sign Project -# -# Have you just finished working on a new firmware for the Sign -# and now want to upload it to the device? This tool automates -# the whole process. Just call 'make release' in the Terminal, -# fix the issues that the tool points out, if any, and the rest -# will be taken care for you. +# Project Tooling Entry Point for 42 Smart Cluster Sign # -# If you want to speed up the process, send the '/ota' command -# in the Telegram chat or press the OTA button on the physical -# device when you are done with this Firmware Release Manager. +# This root Makefile exposes the user-facing commands. Larger tools +# live under tools/ and are delegated to their own Makefiles so the +# repository root stays readable as the tooling grows. ################################################################## - SHELL := /bin/bash -REPO ?= RomanAlexandroff/42-Smart-Cluster-Sign -MANIFEST ?= ota/manifest.json -CONFIG ?= src/config.h -CREDENTIALS ?= src/credentials.h -SKETCH ?= src/src.ino -LIBRARIES_PATH ?= libraries -ARDUINO_BOARD ?= esp32:esp32:XIAO_ESP32C3:UploadSpeed=230400,CDCOnBoot=default,CPUFreq=160,FlashFreq=80,FlashMode=qio,FlashSize=4M,PartitionScheme=min_spiffs,DebugLevel=debug,EraseFlash=none -ARDUINO_CLI_CONFIG ?= tools/arduino-cli/arduino-cli.yaml -RELEASE_DIR ?= build -RELEASE_META ?= $(RELEASE_DIR)/release-meta.json -RELEASE_INFO ?= $(RELEASE_DIR)/release-info.json +SRC_DIR ?= src +FRM_DIR ?= tools/firmware_release_manager -.PHONY: help release publish-manifest +# Neutralize secondary goals for: make format all | make format +ifeq ($(firstword $(MAKECMDGOALS)),format) + FORMAT_ARG := $(word 2,$(MAKECMDGOALS)) + ifneq ($(FORMAT_ARG),) + $(eval $(FORMAT_ARG):;@:) + endif +endif + +.PHONY: help release publish-manifest format help: @printf '%s\n' \ 'Available targets:' \ ' make release Run the Firmware Release Manager.' \ - ' make publish-manifest Update ota/manifest.json from release-info.json.' + ' make publish-manifest Update ota/manifest.json from release-info.json.' \ + ' make format all Format all .ino/.c/.cpp/.h files under src/.' \ + ' make format Format one file under src/ (basename or src/).' release: - @set -euo pipefail; printf '\n[release] Starting OTA release manager\n\n' - @set -euo pipefail; command -v git >/dev/null || { echo 'ERROR: git is required.'; exit 1; } - @set -euo pipefail; command -v gh >/dev/null || { echo 'ERROR: GitHub CLI (gh) is required.'; exit 1; } - @set -euo pipefail; gh auth status -h github.com 2>&1 | grep -q workflow || { echo 'ERROR: GitHub CLI token is missing the workflow scope.'; echo 'Run: gh auth refresh -h github.com -s workflow'; exit 1; } - @set -euo pipefail; command -v arduino-cli >/dev/null || { echo 'ERROR: arduino-cli is required.'; exit 1; } - @set -euo pipefail; command -v python3 >/dev/null || { echo 'ERROR: python3 is required.'; exit 1; } - @set -euo pipefail; test -f "$(ARDUINO_CLI_CONFIG)" || { echo "ERROR: $(ARDUINO_CLI_CONFIG) does not exist."; exit 1; } - @set -euo pipefail; rm -rf "$(RELEASE_DIR)/ota-release"; rm -f "$(RELEASE_DIR)"/*.bin "$(RELEASE_META)" "$(RELEASE_INFO)" - @set -euo pipefail; RELEASE_PREFLIGHT=$$(awk '/^# BEGIN RELEASE_PREFLIGHT_PY/{flag=1;next}/^# END RELEASE_PREFLIGHT_PY/{flag=0}flag{sub(/^# ?/,"");print}' Makefile); python3 -c "$$RELEASE_PREFLIGHT" "$(MANIFEST)" "$(CONFIG)" "$(CREDENTIALS)" "$(RELEASE_META)" - @set -euo pipefail; \ - TAG=$$(python3 -c 'import json,sys; print(json.load(open(sys.argv[1]))["RELEASE_TAG"])' "$(RELEASE_META)"); \ - LOCAL_TAG_EXISTS=0; REMOTE_TAG_EXISTS=0; RELEASE_EXISTS=0; \ - if git rev-parse -q --verify "refs/tags/$$TAG" >/dev/null; then LOCAL_TAG_EXISTS=1; fi; \ - if git ls-remote --exit-code --tags origin "refs/tags/$$TAG" >/dev/null 2>&1; then REMOTE_TAG_EXISTS=1; fi; \ - if gh release view "$$TAG" --repo "$(REPO)" >/dev/null 2>&1; then RELEASE_EXISTS=1; fi; \ - if [ "$$LOCAL_TAG_EXISTS" = "1" ] || [ "$$REMOTE_TAG_EXISTS" = "1" ] || [ "$$RELEASE_EXISTS" = "1" ]; then \ - echo; \ - echo "Release identity $$TAG already exists:"; \ - if [ "$$LOCAL_TAG_EXISTS" = "1" ]; then echo " - local Git tag"; fi; \ - if [ "$$REMOTE_TAG_EXISTS" = "1" ]; then echo " - remote Git tag"; fi; \ - if [ "$$RELEASE_EXISTS" = "1" ]; then echo " - GitHub Release"; fi; \ - echo; \ - printf "Delete the existing release identity and reuse $$TAG? [y/N] "; \ - read -r answer; \ - case "$$answer" in \ - y|Y|yes|YES) ;; \ - *) echo "Release process aborted."; exit 1 ;; \ - esac; \ - if [ "$$RELEASE_EXISTS" = "1" ]; then gh release delete "$$TAG" --repo "$(REPO)" --yes; fi; \ - if [ "$$LOCAL_TAG_EXISTS" = "1" ]; then git tag -d "$$TAG"; fi; \ - if [ "$$REMOTE_TAG_EXISTS" = "1" ]; then git push origin ":refs/tags/$$TAG"; fi; \ - fi - @set -euo pipefail; mkdir -p "$(RELEASE_DIR)"; printf '\n[release] Compiling firmware locally\n\n'; arduino-cli --config-file "$(ARDUINO_CLI_CONFIG)" compile --fqbn "$(ARDUINO_BOARD)" --libraries "$(LIBRARIES_PATH)" --output-dir "$(RELEASE_DIR)" --build-property build.partitions=min_spiffs --build-property upload.maximum_size=1966080 --verbose "$(SKETCH)" - @set -euo pipefail; FIRMWARE_SOURCE=$$(python3 -c 'import pathlib,sys; root=pathlib.Path(sys.argv[1]); firmware_name=pathlib.Path(sys.argv[2]).name + ".bin"; matches=list(root.glob(firmware_name)); sys.exit(f"ERROR: expected firmware binary {firmware_name} was not found in {root}") if len(matches) != 1 else print(matches[0])' "$(RELEASE_DIR)" "$(SKETCH)"); FIRMWARE_ASSET=$$(python3 -c 'import json,sys; print(json.load(open(sys.argv[1]))["FIRMWARE_ASSET"])' "$(RELEASE_META)"); cp "$$FIRMWARE_SOURCE" "$(RELEASE_DIR)/$$FIRMWARE_ASSET" - @set -euo pipefail; FIRMWARE_ASSET=$$(python3 -c 'import json,sys; print(json.load(open(sys.argv[1]))["FIRMWARE_ASSET"])' "$(RELEASE_META)"); FIRMWARE_ASSET_PATH="$(RELEASE_DIR)/$$FIRMWARE_ASSET"; SHA256=$$(python3 -c 'import hashlib,sys; h=hashlib.sha256(); f=open(sys.argv[1],"rb"); [h.update(c) for c in iter(lambda:f.read(1048576), b"")]; print(h.hexdigest())' "$$FIRMWARE_ASSET_PATH"); SIZE=$$(python3 -c 'import pathlib,sys; print(pathlib.Path(sys.argv[1]).stat().st_size)' "$$FIRMWARE_ASSET_PATH"); python3 -c 'import json,pathlib,sys; info=json.load(open(sys.argv[1])); info["SHA-256"]=sys.argv[3]; info["SIZE"]=int(sys.argv[4]); pathlib.Path(sys.argv[2]).write_text(json.dumps(info, indent=2)+"\n"); print(f"[release] Wrote release contract to {sys.argv[2]}")' "$(RELEASE_META)" "$(RELEASE_INFO)" "$$SHA256" "$$SIZE" - @set -euo pipefail; TAG=$$(python3 -c 'import json,sys; print(json.load(open(sys.argv[1]))["RELEASE_TAG"])' "$(RELEASE_META)"); DEVICE_NAME=$$(python3 -c 'import json,sys; print(json.load(open(sys.argv[1]))["DEVICE_NAME"])' "$(RELEASE_META)"); SOFTWARE_VERSION=$$(python3 -c 'import json,sys; print(json.load(open(sys.argv[1]))["SOFTWARE_VERSION"])' "$(RELEASE_META)"); FIRMWARE_ASSET=$$(python3 -c 'import json,sys; print(json.load(open(sys.argv[1]))["FIRMWARE_ASSET"])' "$(RELEASE_META)"); printf '\n[release] Creating GitHub Release %s\n\n' "$$TAG"; gh release create "$$TAG" "$(RELEASE_DIR)/$$FIRMWARE_ASSET" "$(RELEASE_INFO)" --repo "$(REPO)" --target "$$(git rev-parse HEAD)" --title "$$DEVICE_NAME $$SOFTWARE_VERSION" --notes "Automated OTA firmware release for $$DEVICE_NAME, version $$SOFTWARE_VERSION." - @set -euo pipefail; rm -f "$(RELEASE_INFO)"; printf '\n[release] Release complete. Local release-info.json was removed after upload.\n' + @$(MAKE) -C "$(FRM_DIR)" ROOT_DIR="$(CURDIR)" release publish-manifest: - @set -euo pipefail; command -v python3 >/dev/null || { echo 'ERROR: python3 is required.'; exit 1; } - @set -euo pipefail; test -n "$(RELEASE_INFO)" || { echo 'ERROR: RELEASE_INFO is required.'; exit 1; } - @set -euo pipefail; test -f "$(RELEASE_INFO)" || { echo "ERROR: $(RELEASE_INFO) does not exist."; exit 1; } - @set -euo pipefail; awk '/^# BEGIN PUBLISH_MANIFEST_PY/{flag=1;next}/^# END PUBLISH_MANIFEST_PY/{flag=0}flag{sub(/^# ?/,"");print}' Makefile | python3 - "$(MANIFEST)" "$(RELEASE_INFO)" "$(RELEASE_TAG)" "$(REPO)" - -# BEGIN RELEASE_PREFLIGHT_PY -# import json -# import pathlib -# import re -# import subprocess -# import sys -# -# manifest_path = pathlib.Path(sys.argv[1]) -# config_path = pathlib.Path(sys.argv[2]) -# credentials_path = pathlib.Path(sys.argv[3]) -# meta_path = pathlib.Path(sys.argv[4]) -# -# def fail(message): -# print(f"\nERROR: {message}") -# print("Release process aborted.") -# sys.exit(1) -# -# def git_output(*args): -# return subprocess.check_output(["git", *args], text=True).strip() -# -# def parse_define(path, name, quoted): -# pattern = rf"^\s*#\s*define\s+{re.escape(name)}\s+" -# if quoted: -# pattern += r'"([^"]*)"' -# else: -# pattern += r'([^\s/]+)' -# regex = re.compile(pattern) -# for line in path.read_text().splitlines(): -# match = regex.search(line) -# if match: -# return match.group(1).strip() -# fail(f"{name} was not found in {path}") -# -# def parse_active_credential(path, name): -# regex = re.compile(rf"^\s*#\s*define\s+{re.escape(name)}\s+\"([^\"]*)\"") -# for line in path.read_text().splitlines(): -# if line.lstrip().startswith("//"): -# continue -# match = regex.search(line) -# if match: -# return match.group(1) -# fail(f"{name} was not found in active credentials") -# -# def version_parts(value): -# parts = [] -# for part in re.split(r"[^\d]+", str(value)): -# if part: -# parts.append(int(part)) -# return parts or [0] -# -# def compare_versions(left, right): -# left_parts = version_parts(left) -# right_parts = version_parts(right) -# width = max(len(left_parts), len(right_parts)) -# left_parts.extend([0] * (width - len(left_parts))) -# right_parts.extend([0] * (width - len(right_parts))) -# return (left_parts > right_parts) - (left_parts < right_parts) -# -# if not manifest_path.exists(): -# fail(f"{manifest_path} does not exist") -# if not config_path.exists(): -# fail(f"{config_path} does not exist") -# if not credentials_path.exists(): -# fail(f"{credentials_path} does not exist") -# -# status = git_output("status", "--porcelain", "--untracked-files=normal") -# if status: -# print(status) -# fail("Git working tree is not clean") -# -# subprocess.run(["git", "fetch", "--quiet", "origin"], check=False) -# try: -# upstream = git_output("rev-parse", "--abbrev-ref", "--symbolic-full-name", "@{u}") -# except subprocess.CalledProcessError: -# fail("Current branch has no upstream. Push the branch and try again") -# local_sha = git_output("rev-parse", "HEAD") -# upstream_sha = git_output("rev-parse", upstream) -# if local_sha != upstream_sha: -# print(f"\nCurrent branch : {git_output('branch', '--show-current')}") -# print(f"Upstream branch: {upstream}") -# print(f"Local HEAD : {local_sha}") -# print(f"Upstream HEAD : {upstream_sha}") -# fail("Current HEAD is not pushed to GitHub. Push the branch and try again") -# -# tracked_credentials = subprocess.run( -# ["git", "ls-files", "--error-unmatch", str(credentials_path)], -# stdout=subprocess.DEVNULL, -# stderr=subprocess.DEVNULL, -# ) -# if tracked_credentials.returncode == 0: -# fail(f"{credentials_path} is tracked by Git; credentials must stay local") -# -# manifest = json.loads(manifest_path.read_text()) -# devices = manifest.get("devices") -# if not isinstance(devices, dict) or not devices: -# fail("manifest does not contain any devices") -# -# device_names = list(devices.keys()) -# print("Found devices:\n") -# for index, name in enumerate(device_names, start=1): -# print(f"[{index}] {name}") -# -# selection = input("\nSelect exactly one device: ").strip() -# if not selection.isdigit(): -# fail("Device selection must be a number") -# selection_index = int(selection) -# if selection_index < 1 or selection_index > len(device_names): -# fail("Device selection is out of range") -# -# selected_device = device_names[selection_index - 1] -# device_entry = devices[selected_device] -# source_device = parse_define(config_path, "DEVICE_NAME", quoted=True) -# source_version = parse_define(config_path, "SOFTWARE_VERSION", quoted=False) -# wifi_ssid = parse_active_credential(credentials_path, "WIFI_SSID") -# wifi_password = parse_active_credential(credentials_path, "WIFI_PASSWORD") -# manifest_version = str(device_entry.get("version", "")) -# manifest_url = str(device_entry.get("url", "")) -# manifest_sha256 = str(device_entry.get("sha256", "")) -# manifest_size = device_entry.get("size", "") -# manifest_enabled = device_entry.get("enabled", "") -# -# if selected_device != source_device: -# print(f"\nSelected device : {selected_device}") -# print(f"DEVICE_NAME in the source code: {source_device}") -# fail("Device mismatch. Change DEVICE_NAME in config.h and try again") -# -# if compare_versions(source_version, manifest_version) <= 0: -# print("\nWARNING: Source firmware version is not newer than the published manifest version.") -# -# tag = f"v{source_version}" -# asset_device_name = source_device.replace(" ", "_") -# asset_name = f"firmware_{asset_device_name}_v.{source_version}.bin" -# current_branch = git_output("branch", "--show-current") -# -# print("\n==================================================") -# print("\nDevice\n------") -# print(f"Manifest device name : {selected_device}") -# print(f"Source code DEVICE_NAME: {source_device}") -# print("\nVersions\n--------") -# print(f"Manifest SW number : {manifest_version}") -# print(f"Source code SW number: {source_version}") -# print("\nWiFi\n----") -# print(f"SSID : {wifi_ssid}") -# print(f"Password : {wifi_password}") -# print("\nManifest\n--------") -# print(f"URL : {manifest_url}") -# print(f"SHA-256 : {manifest_sha256}") -# print(f"Size : {manifest_size} bytes") -# print(f"OTA enabled : {manifest_enabled}") -# print("\nGit\n---") -# print(f"Branch : {current_branch}") -# print("Working tree : Clean") -# print("\nRelease\n-------") -# print(f"Tag : {tag}") -# print(f"Firmware asset : {asset_name}") -# print("\n==================================================\n") -# -# answer = input("Continue? [y/N] ").strip().lower() -# if answer not in {"y", "yes"}: -# fail("User declined release confirmation") -# -# meta_path.parent.mkdir(parents=True, exist_ok=True) -# meta_path.write_text(json.dumps({ -# "DEVICE_NAME": source_device, -# "SOFTWARE_VERSION": source_version, -# "MANIFEST_VERSION": manifest_version, -# "FIRMWARE_ASSET": asset_name, -# "RELEASE_TAG": tag, -# }, indent=2) + "\n") -# print(f"\n[release] Wrote local metadata to {meta_path}") -# END RELEASE_PREFLIGHT_PY + @$(MAKE) -C "$(FRM_DIR)" ROOT_DIR="$(CURDIR)" publish-manifest -# BEGIN PUBLISH_MANIFEST_PY -# import json -# import pathlib -# import re -# import sys -# import urllib.parse -# -# manifest_path = pathlib.Path(sys.argv[1]) -# release_info_path = pathlib.Path(sys.argv[2]) -# release_tag_arg = sys.argv[3].strip() -# repo = sys.argv[4].strip() -# -# def fail(message): -# print(f"ERROR: {message}", file=sys.stderr) -# sys.exit(1) -# -# def find_matching_brace(text, start_index): -# depth = 0 -# in_string = False -# escaped = False -# for index in range(start_index, len(text)): -# char = text[index] -# if in_string: -# if escaped: -# escaped = False -# elif char == "\\": -# escaped = True -# elif char == '"': -# in_string = False -# continue -# if char == '"': -# in_string = True -# elif char == "{": -# depth += 1 -# elif char == "}": -# depth -= 1 -# if depth == 0: -# return index -# fail("could not find matching manifest device block brace") -# -# def json_value(value): -# return json.dumps(value, ensure_ascii=False) -# -# def replace_field(block, field, value): -# pattern = re.compile( -# rf'("{re.escape(field)}"\s*:\s*)(?:"(?:\\.|[^"\\])*"|\d+|true|false|null)' -# ) -# updated, count = pattern.subn(lambda match: match.group(1) + json_value(value), block, count=1) -# if count != 1: -# fail(f"field {field} was not found exactly once in selected manifest block") -# return updated -# -# info = json.loads(release_info_path.read_text()) -# device = info.get("DEVICE_NAME") -# version = info.get("SOFTWARE_VERSION") -# sha256 = info.get("SHA-256") -# size = info.get("SIZE") -# asset_name = info.get("FIRMWARE_ASSET") -# release_tag = release_tag_arg or info.get("RELEASE_TAG") -# -# if not all([device, version, sha256, size, asset_name, release_tag]): -# fail("release-info.json is missing required metadata") -# -# firmware_url = ( -# f"https://github.com/{repo}/releases/download/" -# f"{urllib.parse.quote(str(release_tag), safe='')}/" -# f"{urllib.parse.quote(str(asset_name), safe='')}" -# ) -# -# text = manifest_path.read_text() -# manifest_before = json.loads(text) -# devices = manifest_before.get("devices", {}) -# if device not in devices: -# fail(f"device {device!r} was not found in manifest") -# enabled_before = devices[device].get("enabled") -# -# device_match = re.search(rf'"{re.escape(device)}"\s*:\s*\{{', text) -# if not device_match: -# fail(f"device block for {device!r} was not found in manifest text") -# block_start = text.find("{", device_match.start()) -# block_end = find_matching_brace(text, block_start) -# block = text[block_start:block_end + 1] -# block = replace_field(block, "version", str(version)) -# block = replace_field(block, "url", firmware_url) -# block = replace_field(block, "sha256", str(sha256)) -# block = replace_field(block, "size", int(size)) -# updated_text = text[:block_start] + block + text[block_end + 1:] -# -# manifest_after = json.loads(updated_text) -# enabled_after = manifest_after["devices"][device].get("enabled") -# if enabled_before != enabled_after: -# fail("manifest enabled field changed unexpectedly") -# if set(manifest_before.get("devices", {}).keys()) != set(manifest_after.get("devices", {}).keys()): -# fail("manifest device set changed unexpectedly") -# -# manifest_path.write_text(updated_text) -# print(f"Updated {manifest_path} for {device} {version}") -# print(f"Firmware URL: {firmware_url}") -# END PUBLISH_MANIFEST_PY +format: + @set -euo pipefail; \ + command -v clang-format >/dev/null || { echo 'ERROR: clang-format is required.'; exit 1; }; \ + arg="$(FORMAT_ARG)"; \ + if [ -z "$$arg" ]; then \ + echo 'ERROR: missing format target.'; \ + echo 'Usage: make format all'; \ + echo ' make format '; \ + exit 1; \ + fi; \ + files=(); \ + if [ "$$arg" = "all" ]; then \ + shopt -s nullglob; \ + candidates=("$(SRC_DIR)"/*.ino "$(SRC_DIR)"/*.c "$(SRC_DIR)"/*.cpp "$(SRC_DIR)"/*.h); \ + if [ "$${#candidates[@]}" -eq 0 ]; then echo "ERROR: no source files found in $(SRC_DIR)/."; exit 1; fi; \ + while IFS= read -r f; do files+=("$$f"); done < <(printf '%s\n' "$${candidates[@]}" | LC_ALL=C sort); \ + else \ + candidate="$$arg"; \ + if [ ! -f "$$candidate" ]; then candidate="$(SRC_DIR)/$$(basename "$$arg")"; fi; \ + if [ ! -f "$$candidate" ]; then echo "ERROR: file not found under $(SRC_DIR)/: $$arg"; exit 1; fi; \ + resolved=$$(cd "$(SRC_DIR)" && pwd)/$$(basename "$$candidate"); \ + actual=$$(cd "$$(dirname "$$candidate")" && pwd)/$$(basename "$$candidate"); \ + if [ "$$actual" != "$$resolved" ]; then echo "ERROR: only files under $(SRC_DIR)/ can be formatted: $$arg"; exit 1; fi; \ + files=("$$candidate"); \ + fi; \ + printf '[format] Formatting %s file(s) with clang-format\n' "$${#files[@]}"; \ + clang-format -i -style=file -- "$${files[@]}" diff --git a/tools/firmware_release_manager/Makefile b/tools/firmware_release_manager/Makefile new file mode 100644 index 0000000..c788517 --- /dev/null +++ b/tools/firmware_release_manager/Makefile @@ -0,0 +1,85 @@ +################################################################## +# Firmware Release Manager for the 42 Smart Cluster Sign Project +# +# This Makefile owns the release-manager internals. It is normally +# called by the root Makefile, but it can also be run directly from +# this folder because all project paths are resolved from ROOT_DIR. +# +# If you want to speed up the OTA flow, send the '/ota' command in +# the Telegram chat or press the OTA button on the physical device +# when this Firmware Release Manager is done. +################################################################## + +SHELL := /bin/bash + +ROOT_DIR ?= $(abspath ../..) +SCRIPT_DIR := $(abspath $(dir $(lastword $(MAKEFILE_LIST)))) + +REPO ?= RomanAlexandroff/42-Smart-Cluster-Sign +MANIFEST ?= $(ROOT_DIR)/ota/manifest.json +CONFIG ?= $(ROOT_DIR)/src/config.h +CREDENTIALS ?= $(ROOT_DIR)/src/credentials.h +SKETCH ?= $(ROOT_DIR)/src/src.ino +LIBRARIES_PATH ?= $(ROOT_DIR)/libraries +ARDUINO_BOARD ?= esp32:esp32:XIAO_ESP32C3:UploadSpeed=230400,CDCOnBoot=default,CPUFreq=160,FlashFreq=80,FlashMode=qio,FlashSize=4M,PartitionScheme=min_spiffs,DebugLevel=debug,EraseFlash=none +ARDUINO_CLI_CONFIG ?= $(ROOT_DIR)/tools/arduino-cli/arduino-cli.yaml +RELEASE_DIR ?= $(ROOT_DIR)/build +RELEASE_META ?= $(RELEASE_DIR)/release-meta.json +RELEASE_INFO ?= $(RELEASE_DIR)/release-info.json + +RELEASE_PREFLIGHT := $(SCRIPT_DIR)/release_preflight.py +RELEASE_INFO_TOOL := $(SCRIPT_DIR)/release_info.py +PUBLISH_MANIFEST_TOOL := $(SCRIPT_DIR)/publish_manifest.py + +.PHONY: help release publish-manifest + +help: + @printf '%s\n' \ + 'Firmware Release Manager targets:' \ + ' make release Build and publish an OTA firmware release.' \ + ' make publish-manifest Update ota/manifest.json from release-info.json.' + +release: + @set -euo pipefail; printf '\n[release] Starting OTA release manager\n\n' + @set -euo pipefail; command -v git >/dev/null || { echo 'ERROR: git is required.'; exit 1; } + @set -euo pipefail; command -v gh >/dev/null || { echo 'ERROR: GitHub CLI (gh) is required.'; exit 1; } + @set -euo pipefail; gh auth status -h github.com 2>&1 | grep -q workflow || { echo 'ERROR: GitHub CLI token is missing the workflow scope.'; echo 'Run: gh auth refresh -h github.com -s workflow'; exit 1; } + @set -euo pipefail; command -v arduino-cli >/dev/null || { echo 'ERROR: arduino-cli is required.'; exit 1; } + @set -euo pipefail; command -v python3 >/dev/null || { echo 'ERROR: python3 is required.'; exit 1; } + @set -euo pipefail; test -f "$(ARDUINO_CLI_CONFIG)" || { echo "ERROR: $(ARDUINO_CLI_CONFIG) does not exist."; exit 1; } + @set -euo pipefail; rm -rf "$(RELEASE_DIR)/ota-release"; rm -f "$(RELEASE_DIR)"/*.bin "$(RELEASE_META)" "$(RELEASE_INFO)" + @set -euo pipefail; cd "$(ROOT_DIR)" && python3 "$(RELEASE_PREFLIGHT)" "$(MANIFEST)" "$(CONFIG)" "$(CREDENTIALS)" "$(RELEASE_META)" + @set -euo pipefail; \ + TAG=$$(python3 "$(RELEASE_INFO_TOOL)" read-meta "$(RELEASE_META)" RELEASE_TAG); \ + LOCAL_TAG_EXISTS=0; REMOTE_TAG_EXISTS=0; RELEASE_EXISTS=0; \ + if git -C "$(ROOT_DIR)" rev-parse -q --verify "refs/tags/$$TAG" >/dev/null; then LOCAL_TAG_EXISTS=1; fi; \ + if git -C "$(ROOT_DIR)" ls-remote --exit-code --tags origin "refs/tags/$$TAG" >/dev/null 2>&1; then REMOTE_TAG_EXISTS=1; fi; \ + if gh release view "$$TAG" --repo "$(REPO)" >/dev/null 2>&1; then RELEASE_EXISTS=1; fi; \ + if [ "$$LOCAL_TAG_EXISTS" = "1" ] || [ "$$REMOTE_TAG_EXISTS" = "1" ] || [ "$$RELEASE_EXISTS" = "1" ]; then \ + echo; \ + echo "Release identity $$TAG already exists:"; \ + if [ "$$LOCAL_TAG_EXISTS" = "1" ]; then echo " - local Git tag"; fi; \ + if [ "$$REMOTE_TAG_EXISTS" = "1" ]; then echo " - remote Git tag"; fi; \ + if [ "$$RELEASE_EXISTS" = "1" ]; then echo " - GitHub Release"; fi; \ + echo; \ + printf "Delete the existing release identity and reuse $$TAG? [y/N] "; \ + read -r answer; \ + case "$$answer" in \ + y|Y|yes|YES) ;; \ + *) echo "Release process aborted."; exit 1 ;; \ + esac; \ + if [ "$$RELEASE_EXISTS" = "1" ]; then gh release delete "$$TAG" --repo "$(REPO)" --yes; fi; \ + if [ "$$LOCAL_TAG_EXISTS" = "1" ]; then git -C "$(ROOT_DIR)" tag -d "$$TAG"; fi; \ + if [ "$$REMOTE_TAG_EXISTS" = "1" ]; then git -C "$(ROOT_DIR)" push origin ":refs/tags/$$TAG"; fi; \ + fi + @set -euo pipefail; mkdir -p "$(RELEASE_DIR)"; printf '\n[release] Compiling firmware locally\n\n'; arduino-cli --config-file "$(ARDUINO_CLI_CONFIG)" compile --fqbn "$(ARDUINO_BOARD)" --libraries "$(LIBRARIES_PATH)" --output-dir "$(RELEASE_DIR)" --build-property build.partitions=min_spiffs --build-property upload.maximum_size=1966080 --verbose "$(SKETCH)" + @set -euo pipefail; FIRMWARE_SOURCE=$$(python3 "$(RELEASE_INFO_TOOL)" find-firmware "$(RELEASE_DIR)" "$(SKETCH)"); FIRMWARE_ASSET=$$(python3 "$(RELEASE_INFO_TOOL)" read-meta "$(RELEASE_META)" FIRMWARE_ASSET); cp "$$FIRMWARE_SOURCE" "$(RELEASE_DIR)/$$FIRMWARE_ASSET" + @set -euo pipefail; FIRMWARE_ASSET=$$(python3 "$(RELEASE_INFO_TOOL)" read-meta "$(RELEASE_META)" FIRMWARE_ASSET); FIRMWARE_ASSET_PATH="$(RELEASE_DIR)/$$FIRMWARE_ASSET"; python3 "$(RELEASE_INFO_TOOL)" write-release-info "$(RELEASE_META)" "$(RELEASE_INFO)" "$$FIRMWARE_ASSET_PATH" + @set -euo pipefail; TAG=$$(python3 "$(RELEASE_INFO_TOOL)" read-meta "$(RELEASE_META)" RELEASE_TAG); DEVICE_NAME=$$(python3 "$(RELEASE_INFO_TOOL)" read-meta "$(RELEASE_META)" DEVICE_NAME); SOFTWARE_VERSION=$$(python3 "$(RELEASE_INFO_TOOL)" read-meta "$(RELEASE_META)" SOFTWARE_VERSION); FIRMWARE_ASSET=$$(python3 "$(RELEASE_INFO_TOOL)" read-meta "$(RELEASE_META)" FIRMWARE_ASSET); printf '\n[release] Creating GitHub Release %s\n\n' "$$TAG"; gh release create "$$TAG" "$(RELEASE_DIR)/$$FIRMWARE_ASSET" "$(RELEASE_INFO)" --repo "$(REPO)" --target "$$(git -C "$(ROOT_DIR)" rev-parse HEAD)" --title "$$DEVICE_NAME $$SOFTWARE_VERSION" --notes "Automated OTA firmware release for $$DEVICE_NAME, version $$SOFTWARE_VERSION." + @set -euo pipefail; rm -f "$(RELEASE_INFO)"; printf '\n[release] Release complete. Local release-info.json was removed after upload.\n' + +publish-manifest: + @set -euo pipefail; command -v python3 >/dev/null || { echo 'ERROR: python3 is required.'; exit 1; } + @set -euo pipefail; test -n "$(RELEASE_INFO)" || { echo 'ERROR: RELEASE_INFO is required.'; exit 1; } + @set -euo pipefail; test -f "$(RELEASE_INFO)" || { echo "ERROR: $(RELEASE_INFO) does not exist."; exit 1; } + @set -euo pipefail; python3 "$(PUBLISH_MANIFEST_TOOL)" "$(MANIFEST)" "$(RELEASE_INFO)" "$(RELEASE_TAG)" "$(REPO)" diff --git a/tools/firmware_release_manager/publish_manifest.py b/tools/firmware_release_manager/publish_manifest.py new file mode 100644 index 0000000..3458f2e --- /dev/null +++ b/tools/firmware_release_manager/publish_manifest.py @@ -0,0 +1,149 @@ +"""Update the OTA manifest from a Firmware Release Manager contract. + +The script updates only the selected device block in ota/manifest.json. +It preserves the original manifest shape and explicitly checks that the +device set and the `enabled` flag are not changed by the update. +""" + +import json +import pathlib +import re +import sys +import urllib.parse + + +def fail(message): + """Print an error and stop manifest publication.""" + print(f"ERROR: {message}", file=sys.stderr) + sys.exit(1) + + +def find_matching_brace(text, start_index): + """Find the closing brace for a JSON object inside raw manifest text.""" + depth = 0 + in_string = False + escaped = False + for index in range(start_index, len(text)): + char = text[index] + if in_string: + if escaped: + escaped = False + elif char == "\\": + escaped = True + elif char == '"': + in_string = False + continue + if char == '"': + in_string = True + elif char == "{": + depth += 1 + elif char == "}": + depth -= 1 + if depth == 0: + return index + fail("could not find matching manifest device block brace") + + +def json_value(value): + """Serialize one manifest field value.""" + return json.dumps(value, ensure_ascii=False) + + +def replace_field(block, field, value): + """Replace exactly one simple JSON field inside the selected block.""" + pattern = re.compile( + rf'("{re.escape(field)}"\s*:\s*)' + r'(?:"(?:\\.|[^"\\])*"|\d+|true|false|null)' + ) + updated, count = pattern.subn( + lambda match: match.group(1) + json_value(value), block, count=1 + ) + if count != 1: + fail(f"field {field} was not found exactly once in selected manifest block") + return updated + + +def release_url(repo, release_tag, asset_name): + """Build the GitHub Release asset URL used by OTA clients.""" + return ( + f"https://github.com/{repo}/releases/download/" + f"{urllib.parse.quote(str(release_tag), safe='')}/" + f"{urllib.parse.quote(str(asset_name), safe='')}" + ) + + +def load_release_info(release_info_path, release_tag_arg): + """Read and validate release-info.json produced by the release flow.""" + info = json.loads(release_info_path.read_text()) + device = info.get("DEVICE_NAME") + version = info.get("SOFTWARE_VERSION") + sha256 = info.get("SHA-256") + size = info.get("SIZE") + asset_name = info.get("FIRMWARE_ASSET") + release_tag = release_tag_arg or info.get("RELEASE_TAG") + + if not all([device, version, sha256, size, asset_name, release_tag]): + fail("release-info.json is missing required metadata") + + return device, version, sha256, size, asset_name, release_tag + + +def update_manifest(manifest_path, release_info_path, release_tag_arg, repo): + """Apply a release-info.json update to one manifest device entry.""" + device, version, sha256, size, asset_name, release_tag = load_release_info( + release_info_path, release_tag_arg + ) + firmware_url = release_url(repo, release_tag, asset_name) + + text = manifest_path.read_text() + manifest_before = json.loads(text) + devices = manifest_before.get("devices", {}) + if device not in devices: + fail(f"device {device!r} was not found in manifest") + enabled_before = devices[device].get("enabled") + + device_match = re.search(rf'"{re.escape(device)}"\s*:\s*\{{', text) + if not device_match: + fail(f"device block for {device!r} was not found in manifest text") + + block_start = text.find("{", device_match.start()) + block_end = find_matching_brace(text, block_start) + block = text[block_start:block_end + 1] + block = replace_field(block, "version", str(version)) + block = replace_field(block, "url", firmware_url) + block = replace_field(block, "sha256", str(sha256)) + block = replace_field(block, "size", int(size)) + updated_text = text[:block_start] + block + text[block_end + 1:] + + manifest_after = json.loads(updated_text) + enabled_after = manifest_after["devices"][device].get("enabled") + if enabled_before != enabled_after: + fail("manifest enabled field changed unexpectedly") + if set(manifest_before.get("devices", {}).keys()) != set( + manifest_after.get("devices", {}).keys() + ): + fail("manifest device set changed unexpectedly") + + manifest_path.write_text(updated_text) + print(f"Updated {manifest_path} for {device} {version}") + print(f"Firmware URL: {firmware_url}") + + +def main(argv): + """Parse Makefile-provided arguments and update the manifest.""" + if len(argv) != 5: + fail( + "usage: publish_manifest.py " + " " + ) + + manifest_path = pathlib.Path(argv[1]) + release_info_path = pathlib.Path(argv[2]) + release_tag_arg = argv[3].strip() + repo = argv[4].strip() + + update_manifest(manifest_path, release_info_path, release_tag_arg, repo) + + +if __name__ == "__main__": + main(sys.argv) diff --git a/tools/firmware_release_manager/release_info.py b/tools/firmware_release_manager/release_info.py new file mode 100644 index 0000000..dbbccf5 --- /dev/null +++ b/tools/firmware_release_manager/release_info.py @@ -0,0 +1,116 @@ +"""Release metadata helpers for the Firmware Release Manager. + +The Makefile uses this small CLI for operations that were previously +implemented as inline `python3 -c` snippets: reading release metadata, +finding the compiled firmware binary, and writing the upload contract. +""" + +import argparse +import hashlib +import json +import pathlib +import sys + + +def fail(message): + """Print an error and return a non-zero exit code.""" + print(f"ERROR: {message}", file=sys.stderr) + sys.exit(1) + + +def load_json(path): + """Load JSON from a path with a consistent error message.""" + try: + return json.loads(path.read_text()) + except FileNotFoundError: + fail(f"{path} does not exist") + except json.JSONDecodeError as error: + fail(f"{path} is not valid JSON: {error}") + + +def read_meta(args): + """Print one key from release-meta.json.""" + meta_path = pathlib.Path(args.meta) + info = load_json(meta_path) + try: + print(info[args.key]) + except KeyError: + fail(f"{args.key} was not found in {meta_path}") + + +def find_firmware(args): + """Find the firmware binary emitted by arduino-cli for the sketch.""" + release_dir = pathlib.Path(args.release_dir) + firmware_name = pathlib.Path(args.sketch).name + ".bin" + matches = list(release_dir.glob(firmware_name)) + if len(matches) != 1: + fail(f"expected firmware binary {firmware_name} was not found in {release_dir}") + print(matches[0]) + + +def sha256_file(path): + """Calculate a firmware file SHA-256 without loading it all at once.""" + hash_object = hashlib.sha256() + with path.open("rb") as file: + for chunk in iter(lambda: file.read(1048576), b""): + hash_object.update(chunk) + return hash_object.hexdigest() + + +def write_release_info(args): + """Write release-info.json with firmware checksum and size.""" + meta_path = pathlib.Path(args.meta) + output_path = pathlib.Path(args.output) + firmware_path = pathlib.Path(args.firmware) + + info = load_json(meta_path) + if not firmware_path.exists(): + fail(f"{firmware_path} does not exist") + + info["SHA-256"] = sha256_file(firmware_path) + info["SIZE"] = firmware_path.stat().st_size + output_path.write_text(json.dumps(info, indent=2) + "\n") + print(f"[release] Wrote release contract to {output_path}") + + +def build_parser(): + """Create the CLI parser used by the Makefile.""" + parser = argparse.ArgumentParser( + description="Firmware Release Manager metadata helper." + ) + subparsers = parser.add_subparsers(dest="command", required=True) + + read_parser = subparsers.add_parser( + "read-meta", help="print a key from release-meta.json" + ) + read_parser.add_argument("meta") + read_parser.add_argument("key") + read_parser.set_defaults(func=read_meta) + + find_parser = subparsers.add_parser( + "find-firmware", help="locate the compiled firmware binary" + ) + find_parser.add_argument("release_dir") + find_parser.add_argument("sketch") + find_parser.set_defaults(func=find_firmware) + + write_parser = subparsers.add_parser( + "write-release-info", help="write release-info.json" + ) + write_parser.add_argument("meta") + write_parser.add_argument("output") + write_parser.add_argument("firmware") + write_parser.set_defaults(func=write_release_info) + + return parser + + +def main(): + """Dispatch one release-info helper command.""" + parser = build_parser() + args = parser.parse_args() + args.func(args) + + +if __name__ == "__main__": + main() diff --git a/tools/firmware_release_manager/release_preflight.py b/tools/firmware_release_manager/release_preflight.py new file mode 100644 index 0000000..a1aaa06 --- /dev/null +++ b/tools/firmware_release_manager/release_preflight.py @@ -0,0 +1,259 @@ +"""Preflight checks for the Firmware Release Manager. + +This script preserves the release validation that used to be embedded in +the root Makefile: it checks the local Git state, validates the selected +device against source configuration, shows the release summary, and writes +the local release metadata consumed by later Makefile steps. +""" + +import json +import pathlib +import re +import subprocess +import sys + + +def fail(message): + """Print a release-style error and stop the release flow.""" + print(f"\nERROR: {message}") + print("Release process aborted.") + sys.exit(1) + + +def git_output(*args): + """Return stdout from a Git command run in the repository root.""" + return subprocess.check_output(["git", *args], text=True).strip() + + +def parse_define(path, name, quoted): + """Read a #define value from an Arduino/C header file.""" + pattern = rf"^\s*#\s*define\s+{re.escape(name)}\s+" + if quoted: + pattern += r'"([^"]*)"' + else: + pattern += r"([^\s/]+)" + regex = re.compile(pattern) + for line in path.read_text().splitlines(): + match = regex.search(line) + if match: + return match.group(1).strip() + fail(f"{name} was not found in {path}") + + +def parse_active_credential(path, name): + """Read an uncommented credential definition from credentials.h.""" + regex = re.compile(rf'^\s*#\s*define\s+{re.escape(name)}\s+"([^"]*)"') + for line in path.read_text().splitlines(): + if line.lstrip().startswith("//"): + continue + match = regex.search(line) + if match: + return match.group(1) + fail(f"{name} was not found in active credentials") + + +def version_parts(value): + """Split a version string into comparable integer chunks.""" + parts = [] + for part in re.split(r"[^\d]+", str(value)): + if part: + parts.append(int(part)) + return parts or [0] + + +def compare_versions(left, right): + """Compare version-ish strings using their numeric parts.""" + left_parts = version_parts(left) + right_parts = version_parts(right) + width = max(len(left_parts), len(right_parts)) + left_parts.extend([0] * (width - len(left_parts))) + right_parts.extend([0] * (width - len(right_parts))) + return (left_parts > right_parts) - (left_parts < right_parts) + + +def validate_inputs(manifest_path, config_path, credentials_path): + """Fail early if the release inputs are missing.""" + if not manifest_path.exists(): + fail(f"{manifest_path} does not exist") + if not config_path.exists(): + fail(f"{config_path} does not exist") + if not credentials_path.exists(): + fail(f"{credentials_path} does not exist") + + +def validate_git_state(credentials_path): + """Ensure the release starts from a clean, pushed working tree.""" + status = git_output("status", "--porcelain", "--untracked-files=normal") + if status: + print(status) + fail("Git working tree is not clean") + + subprocess.run(["git", "fetch", "--quiet", "origin"], check=False) + try: + upstream = git_output( + "rev-parse", "--abbrev-ref", "--symbolic-full-name", "@{u}" + ) + except subprocess.CalledProcessError: + fail("Current branch has no upstream. Push the branch and try again") + + local_sha = git_output("rev-parse", "HEAD") + upstream_sha = git_output("rev-parse", upstream) + if local_sha != upstream_sha: + print(f"\nCurrent branch : {git_output('branch', '--show-current')}") + print(f"Upstream branch: {upstream}") + print(f"Local HEAD : {local_sha}") + print(f"Upstream HEAD : {upstream_sha}") + fail("Current HEAD is not pushed to GitHub. Push the branch and try again") + + tracked_credentials = subprocess.run( + ["git", "ls-files", "--error-unmatch", str(credentials_path)], + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, + ) + if tracked_credentials.returncode == 0: + fail(f"{credentials_path} is tracked by Git; credentials must stay local") + + +def select_device(devices): + """Prompt the user to pick exactly one manifest device.""" + device_names = list(devices.keys()) + print("Found devices:\n") + for index, name in enumerate(device_names, start=1): + print(f"[{index}] {name}") + + selection = input("\nSelect exactly one device: ").strip() + if not selection.isdigit(): + fail("Device selection must be a number") + selection_index = int(selection) + if selection_index < 1 or selection_index > len(device_names): + fail("Device selection is out of range") + return device_names[selection_index - 1] + + +def print_summary( + selected_device, + source_device, + manifest_version, + source_version, + wifi_ssid, + wifi_password, + manifest_url, + manifest_sha256, + manifest_size, + manifest_enabled, + tag, + asset_name, +): + """Show the same confirmation summary as the original Makefile flow.""" + current_branch = git_output("branch", "--show-current") + + print("\n==================================================") + print("\nDevice\n------") + print(f"Manifest device name : {selected_device}") + print(f"Source code DEVICE_NAME: {source_device}") + print("\nVersions\n--------") + print(f"Manifest SW number : {manifest_version}") + print(f"Source code SW number: {source_version}") + print("\nWiFi\n----") + print(f"SSID : {wifi_ssid}") + print(f"Password : {wifi_password}") + print("\nManifest\n--------") + print(f"URL : {manifest_url}") + print(f"SHA-256 : {manifest_sha256}") + print(f"Size : {manifest_size} bytes") + print(f"OTA enabled : {manifest_enabled}") + print("\nGit\n---") + print(f"Branch : {current_branch}") + print("Working tree : Clean") + print("\nRelease\n-------") + print(f"Tag : {tag}") + print(f"Firmware asset : {asset_name}") + print("\n==================================================\n") + + +def main(argv): + """Run release preflight and write release-meta.json.""" + if len(argv) != 5: + fail( + "usage: release_preflight.py " + " " + ) + + manifest_path = pathlib.Path(argv[1]) + config_path = pathlib.Path(argv[2]) + credentials_path = pathlib.Path(argv[3]) + meta_path = pathlib.Path(argv[4]) + + validate_inputs(manifest_path, config_path, credentials_path) + validate_git_state(credentials_path) + + manifest = json.loads(manifest_path.read_text()) + devices = manifest.get("devices") + if not isinstance(devices, dict) or not devices: + fail("manifest does not contain any devices") + + selected_device = select_device(devices) + device_entry = devices[selected_device] + source_device = parse_define(config_path, "DEVICE_NAME", quoted=True) + source_version = parse_define(config_path, "SOFTWARE_VERSION", quoted=False) + wifi_ssid = parse_active_credential(credentials_path, "WIFI_SSID") + wifi_password = parse_active_credential(credentials_path, "WIFI_PASSWORD") + manifest_version = str(device_entry.get("version", "")) + manifest_url = str(device_entry.get("url", "")) + manifest_sha256 = str(device_entry.get("sha256", "")) + manifest_size = device_entry.get("size", "") + manifest_enabled = device_entry.get("enabled", "") + + if selected_device != source_device: + print(f"\nSelected device : {selected_device}") + print(f"DEVICE_NAME in the source code: {source_device}") + fail("Device mismatch. Change DEVICE_NAME in config.h and try again") + + if compare_versions(source_version, manifest_version) <= 0: + print( + "\nWARNING: Source firmware version is not newer than " + "the published manifest version." + ) + + tag = f"v{source_version}" + asset_device_name = source_device.replace(" ", "_") + asset_name = f"firmware_{asset_device_name}_v.{source_version}.bin" + + print_summary( + selected_device, + source_device, + manifest_version, + source_version, + wifi_ssid, + wifi_password, + manifest_url, + manifest_sha256, + manifest_size, + manifest_enabled, + tag, + asset_name, + ) + + answer = input("Continue? [y/N] ").strip().lower() + if answer not in {"y", "yes"}: + fail("User declined release confirmation") + + meta_path.parent.mkdir(parents=True, exist_ok=True) + meta_path.write_text( + json.dumps( + { + "DEVICE_NAME": source_device, + "SOFTWARE_VERSION": source_version, + "MANIFEST_VERSION": manifest_version, + "FIRMWARE_ASSET": asset_name, + "RELEASE_TAG": tag, + }, + indent=2, + ) + + "\n" + ) + print(f"\n[release] Wrote local metadata to {meta_path}") + + +if __name__ == "__main__": + main(sys.argv)