Skip to content

Commit 28a019f

Browse files
committed
tools: cloud: application/release CLI
Add subcommands to `cloud` to display applications and releases and upload releases from a release folder. Signed-off-by: Jordan Yates <jordan@embeint.com>
1 parent 15c495a commit 28a019f

1 file changed

Lines changed: 248 additions & 2 deletions

File tree

src/infuse_iot/tools/cloud.py

Lines changed: 248 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,13 +5,23 @@
55
__author__ = "Jordan Yates"
66
__copyright__ = "Copyright 2024, Embeint Holdings Pty Ltd"
77

8+
import glob
89
import sys
910
from typing import Any
11+
from uuid import UUID
1012

1113
from tabulate import tabulate
1214

1315
import infuse_iot.api_client.models as models
1416
from infuse_iot.api_client import Client
17+
from infuse_iot.api_client.api.application import (
18+
create_application,
19+
create_release,
20+
create_release_diff,
21+
get_application_by_organisation_id_and_application_id,
22+
get_applications_by_organisation_id,
23+
get_releases_by_organisation_id_and_application_id,
24+
)
1525
from infuse_iot.api_client.api.board import (
1626
create_board,
1727
get_board_by_id,
@@ -30,10 +40,12 @@
3040
get_all_organisations,
3141
get_organisation_by_id,
3242
)
33-
from infuse_iot.api_client.models import COAPFilesList, Error, NewBoard, NewOrganisation
34-
from infuse_iot.api_client.types import Unset
43+
from infuse_iot.api_client.types import File, Unset
3544
from infuse_iot.commands import InfuseCommand
3645
from infuse_iot.credentials import get_api_key
46+
from infuse_iot.util.argparse import ValidRelease
47+
from infuse_iot.util.console import choose_one, user_confirm, user_response
48+
from infuse_iot.util.version import Version
3749

3850

3951
class CloudSubCommand:
@@ -322,6 +334,239 @@ def list(self, client: Client):
322334
print("\t" + "\n\t".join(sorted_list))
323335

324336

337+
class Applications(CloudSubCommand):
338+
@classmethod
339+
def add_parser(cls, parser):
340+
parser_coap = parser.add_parser("apps", help="Application release management")
341+
parser_coap.set_defaults(command_class=cls)
342+
343+
tool_parser = parser_coap.add_subparsers(title="commands", metavar="<command>", required=True)
344+
345+
list_parser = tool_parser.add_parser("list", help="List all application releases")
346+
list_parser.add_argument("--org", "-o", type=str, required=True, help="Organisation ID")
347+
list_parser.set_defaults(command_fn=cls.list)
348+
349+
info_parser = tool_parser.add_parser("info", help="Display summary of application releases")
350+
info_parser.add_argument("--org", "-o", type=str, required=True, help="Organisation ID")
351+
info_parser.add_argument("--app", "-a", type=lambda x: int(x, 16), required=True, help="Application ID (hex)")
352+
info_parser.set_defaults(command_fn=cls.info)
353+
354+
upload_parser = tool_parser.add_parser("upload", help="Upload application release")
355+
upload_parser.add_argument("--org", "-o", type=str, help="Organisation ID")
356+
upload_parser.add_argument("--board", "-b", type=str, help="Board ID")
357+
upload_parser.add_argument("--release", "-r", type=ValidRelease, required=True, help="Release to upload")
358+
upload_parser.set_defaults(command_fn=cls.upload)
359+
360+
def run(self):
361+
with self.client() as client:
362+
self.args.command_fn(self, client)
363+
364+
def list(self, client: Client):
365+
applications = get_applications_by_organisation_id.sync(client=client, id=UUID(self.args.org))
366+
367+
if not isinstance(applications, list):
368+
print(f"Failed to retrieve application list {applications}")
369+
return
370+
371+
app_list = []
372+
for app in applications:
373+
app_list.append(
374+
[
375+
f"0x{app.id:08X}",
376+
app.name,
377+
app.description,
378+
]
379+
)
380+
print(
381+
tabulate(
382+
app_list,
383+
headers=["ID", "Name", "Description"],
384+
)
385+
)
386+
387+
def info(self, client: Client):
388+
releases = get_releases_by_organisation_id_and_application_id.sync(
389+
client=client, id=UUID(self.args.org), application_id=self.args.app
390+
)
391+
392+
if releases is None:
393+
sys.exit("Failed to retrieve release list (No response)")
394+
elif isinstance(releases, models.Error):
395+
sys.exit(f"<{releases.code}>: {releases.message}")
396+
397+
release_list = []
398+
for release in releases:
399+
version = release.version
400+
version_str = f"{version.major}.{version.minor}.{version.revision}+{version.build_num:08x}"
401+
release_list.append(
402+
[
403+
f"{release.board_target}",
404+
version_str,
405+
f"{release.id}",
406+
f"{release.file.len_ / 1024:.2f} kB",
407+
str(release.created_at),
408+
]
409+
)
410+
print(
411+
tabulate(
412+
release_list,
413+
headers=["Board Target", "Version", "ID", "Full OTA", "Created"],
414+
)
415+
)
416+
417+
def upload(self, client: Client):
418+
try:
419+
self._board = UUID(self.args.board) if self.args.board else None
420+
except ValueError:
421+
sys.exit(f"Board ID: '{self.args.board}' is not a valid UUID")
422+
try:
423+
self._org = UUID(self.args.org) if self.args.org else None
424+
except ValueError:
425+
sys.exit(f"Organisation ID: '{self.args.org}' is not a valid UUID")
426+
427+
release: ValidRelease = self.args.release
428+
release_app_meta = release.metadata["application"]
429+
name = release_app_meta["primary"]
430+
app_id = release_app_meta["id"]
431+
board_target = release_app_meta["board"]
432+
version = Version.from_string(release_app_meta["version"])
433+
434+
if self._org is None:
435+
orgs = get_all_organisations.sync(client=client)
436+
if isinstance(orgs, models.Error) or orgs is None:
437+
sys.exit(f"Organisation query failed {orgs}")
438+
options = [f"{o.name:20s} ({o.id})" for o in orgs]
439+
440+
idx, _val = choose_one("Organisation", options)
441+
self._org = orgs[idx].id
442+
self._org_name = orgs[idx].name
443+
else:
444+
org = get_organisation_by_id.sync(client=client, id=self._org)
445+
if not isinstance(org, models.Organisation):
446+
sys.exit(f"Failed to query org for ID {self._org}")
447+
self._org_name = org.name
448+
449+
if self._board is None:
450+
boards = get_boards.sync(client=client, organisation_id=self._org)
451+
if isinstance(boards, models.Error) or boards is None:
452+
sys.exit(f"Board query failed {boards}")
453+
options = [f"{b.name:20s} ({b.id})" for b in boards]
454+
455+
idx, _val = choose_one("Board", options)
456+
self._board = boards[idx].id
457+
self._board_name = boards[idx].name
458+
else:
459+
board = get_board_by_id.sync(client=client, id=self._board)
460+
if not isinstance(org, models.Board):
461+
sys.exit(f"Failed to query board for ID {self._board}")
462+
self._board_name = board.name
463+
464+
application = get_application_by_organisation_id_and_application_id.sync(
465+
client=client, id=self._org, application_id=app_id
466+
)
467+
468+
if application is None:
469+
dialog = f"Application 0x{app_id:08x} does not exist in organisation {self.args.org}, create?"
470+
if not user_confirm(dialog):
471+
return
472+
print(f"Creating application 0x{app_id:08x} in organisation {self.args.org}")
473+
description = user_response("Application description:")
474+
body = models.NewApplication(id=app_id, name=name, description=description)
475+
application = create_application.sync(client=client, id=self._org, body=body)
476+
477+
if not isinstance(application, models.Application):
478+
sys.exit(f"Unexpected internal type {type(application)}")
479+
480+
ota_files = glob.glob(str(release.dir / "ota-*.bin"))
481+
if len(ota_files) != 1:
482+
sys.exit(f"Unexpected OTA file search result {ota_files}")
483+
484+
releases = get_releases_by_organisation_id_and_application_id.sync(
485+
client=client,
486+
id=self._org,
487+
application_id=app_id,
488+
)
489+
if not isinstance(releases, list):
490+
sys.exit(f"Unexpected release query result {releases}")
491+
492+
cloud_release: None | models.ApplicationRelease = None
493+
cloud_releases_by_version: dict[Version, models.ApplicationRelease] = {}
494+
for r in releases:
495+
v = Version(r.version.major, r.version.minor, r.version.revision, r.version.build_num)
496+
cloud_releases_by_version[v] = r
497+
if v == version:
498+
print(f"Found release for application '0x{app_id:08x} {str(version)}' ({r.id})")
499+
cloud_release = r
500+
501+
if cloud_release is None:
502+
dialog = (
503+
f"Create release for application '0x{app_id:08x} {str(version)}'"
504+
+ f" in organisation '{self._org_name}' for board '{self._board_name}'?"
505+
)
506+
if not user_confirm(dialog):
507+
return
508+
509+
with open(ota_files[0], "rb") as f:
510+
ota_file = File(f, ota_files[0], None)
511+
512+
release_obj = models.CreateReleaseBody(
513+
file=ota_file,
514+
file_diff_len=str(0),
515+
version_major=str(version.major),
516+
version_minor=str(version.minor),
517+
version_revision=str(version.revision),
518+
version_build_num=str(version.build_num),
519+
board_id=self._board,
520+
board_target=board_target,
521+
)
522+
523+
rsp = create_release.sync(
524+
client=client,
525+
id=self._org,
526+
application_id=app_id,
527+
body=release_obj,
528+
)
529+
if rsp is None:
530+
sys.exit("Create release: No response")
531+
elif isinstance(rsp, models.Error):
532+
sys.exit(f"<{rsp.code}>: {rsp.message}")
533+
else:
534+
print(f"Release created with ID '{rsp.id}'")
535+
cloud_release = rsp
536+
537+
# Upload any diffs
538+
diff_folder = release.dir / "diffs"
539+
if not diff_folder.exists():
540+
return
541+
for path in diff_folder.iterdir():
542+
if not path.is_file() or path.suffix != ".bin":
543+
continue
544+
try:
545+
diff_from_version = Version.from_string(path.stem)
546+
except ValueError:
547+
print(f"Couldn't parse diff files version '{path.stem}'")
548+
continue
549+
from_version = cloud_releases_by_version.get(diff_from_version)
550+
if from_version is None:
551+
print(f"Version {diff_from_version} doesn't exist on cloud")
552+
continue
553+
554+
with open(path, "rb") as f:
555+
diff_file = File(f, str(path), None)
556+
557+
create_body = models.CreateReleaseDiffBody(file=diff_file, from_release_id=from_version.id)
558+
diff_rsp = create_release_diff.sync(
559+
client=client, id=self._org, application_id=app_id, release_id=cloud_release.id, body=create_body
560+
)
561+
prefix = f"{str(diff_from_version)} -> {str(version)}"
562+
if isinstance(diff_rsp, models.Error):
563+
print(f"{prefix}: <{diff_rsp.code}> {diff_rsp.message}")
564+
elif isinstance(diff_rsp, models.ApplicationReleaseDiff):
565+
print(f"{prefix}: Diff created with ID '{diff_rsp.id}'")
566+
else:
567+
print(f"{prefix}: No response")
568+
569+
325570
class SubCommand(InfuseCommand):
326571
NAME = "cloud"
327572
HELP = "Infuse-IoT cloud interaction"
@@ -335,6 +580,7 @@ def add_parser(cls, parser):
335580
Boards.add_parser(subparser)
336581
Device.add_parser(subparser)
337582
Coap.add_parser(subparser)
583+
Applications.add_parser(subparser)
338584

339585
def __init__(self, args):
340586
self.tool = args.command_class(args)

0 commit comments

Comments
 (0)