Skip to content

Commit 1d09587

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 909cdce commit 1d09587

1 file changed

Lines changed: 196 additions & 2 deletions

File tree

src/infuse_iot/tools/cloud.py

Lines changed: 196 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,13 +5,22 @@
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+
get_application_by_organisation_id_and_application_id,
21+
get_applications_by_organisation_id,
22+
get_releases_by_organisation_id_and_application_id,
23+
)
1524
from infuse_iot.api_client.api.board import (
1625
create_board,
1726
get_board_by_id,
@@ -30,10 +39,12 @@
3039
get_all_organisations,
3140
get_organisation_by_id,
3241
)
33-
from infuse_iot.api_client.models import COAPFilesList, Error, NewBoard, NewOrganisation
34-
from infuse_iot.api_client.types import Unset
42+
from infuse_iot.api_client.types import File, Unset
3543
from infuse_iot.commands import InfuseCommand
3644
from infuse_iot.credentials import get_api_key
45+
from infuse_iot.util.argparse import ValidRelease
46+
from infuse_iot.util.console import choose_one, user_confirm, user_response
47+
from infuse_iot.util.version import Version
3748

3849

3950
class CloudSubCommand:
@@ -322,6 +333,188 @@ def list(self, client: Client):
322333
print("\t" + "\n\t".join(sorted_list))
323334

324335

336+
class Applications(CloudSubCommand):
337+
@classmethod
338+
def add_parser(cls, parser):
339+
parser_coap = parser.add_parser("apps", help="Application release management")
340+
parser_coap.set_defaults(command_class=cls)
341+
342+
tool_parser = parser_coap.add_subparsers(title="commands", metavar="<command>", required=True)
343+
344+
list_parser = tool_parser.add_parser("list", help="List all application releases")
345+
list_parser.add_argument("--org", "-o", type=str, required=True, help="Organisation ID")
346+
list_parser.set_defaults(command_fn=cls.list)
347+
348+
info_parser = tool_parser.add_parser("info", help="Display summary of application releases")
349+
info_parser.add_argument("--org", "-o", type=str, required=True, help="Organisation ID")
350+
info_parser.add_argument("--app", "-a", type=lambda x: int(x, 16), required=True, help="Application ID (hex)")
351+
info_parser.set_defaults(command_fn=cls.info)
352+
353+
upload_parser = tool_parser.add_parser("upload", help="Upload application release")
354+
upload_parser.add_argument("--org", "-o", type=str, help="Organisation ID")
355+
upload_parser.add_argument("--board", "-b", type=str, help="Board ID")
356+
upload_parser.add_argument("--release", "-r", type=ValidRelease, required=True, help="Release to upload")
357+
upload_parser.set_defaults(command_fn=cls.upload)
358+
359+
def run(self):
360+
with self.client() as client:
361+
self.args.command_fn(self, client)
362+
363+
def list(self, client: Client):
364+
applications = get_applications_by_organisation_id.sync(client=client, id=UUID(self.args.org))
365+
366+
if not isinstance(applications, list):
367+
print(f"Failed to retrieve application list {applications}")
368+
return
369+
370+
app_list = []
371+
for app in applications:
372+
app_list.append(
373+
[
374+
f"0x{app.id:08X}",
375+
app.name,
376+
app.description,
377+
]
378+
)
379+
print(
380+
tabulate(
381+
app_list,
382+
headers=["ID", "Name", "Description"],
383+
)
384+
)
385+
386+
def info(self, client: Client):
387+
releases = get_releases_by_organisation_id_and_application_id.sync(
388+
client=client, id=UUID(self.args.org), application_id=self.args.app
389+
)
390+
391+
if releases is None:
392+
sys.exit("Failed to retrieve release list (No response)")
393+
elif isinstance(releases, models.Error):
394+
sys.exit(f"<{releases.code}>: {releases.message}")
395+
396+
release_list = []
397+
for release in releases:
398+
version = release.version
399+
version_str = f"{version.major}.{version.minor}.{version.revision}+{version.build_num:08x}"
400+
release_list.append(
401+
[
402+
f"{release.board_target}",
403+
version_str,
404+
f"{release.id}",
405+
f"{release.file.len_ / 1024:.2f} kB",
406+
str(release.created_at),
407+
]
408+
)
409+
print(
410+
tabulate(
411+
release_list,
412+
headers=["Board Target", "Version", "ID", "Full OTA", "Created"],
413+
)
414+
)
415+
416+
def upload(self, client: Client):
417+
try:
418+
self._board = UUID(self.args.board) if self.args.board else None
419+
except ValueError:
420+
sys.exit(f"Board ID: '{self.args.board}' is not a valid UUID")
421+
try:
422+
self._org = UUID(self.args.org) if self.args.org else None
423+
except ValueError:
424+
sys.exit(f"Organisation ID: '{self.args.org}' is not a valid UUID")
425+
426+
release: ValidRelease = self.args.release
427+
release_app_meta = release.metadata["application"]
428+
name = release_app_meta["primary"]
429+
app_id = release_app_meta["id"]
430+
board_target = release_app_meta["board"]
431+
version = Version.from_string(release_app_meta["version"])
432+
433+
if self._org is None:
434+
orgs = get_all_organisations.sync(client=client)
435+
if isinstance(orgs, models.Error) or orgs is None:
436+
sys.exit(f"Organisation query failed {orgs}")
437+
options = [f"{o.name:20s} ({o.id})" for o in orgs]
438+
439+
idx, _val = choose_one("Organisation", options)
440+
self._org = orgs[idx].id
441+
self._org_name = orgs[idx].name
442+
else:
443+
org = get_organisation_by_id.sync(client=client, id=self._org)
444+
if not isinstance(org, models.Organisation):
445+
sys.exit(f"Failed to query org for ID {self._org}")
446+
self._org_name = org.name
447+
448+
if self._board is None:
449+
boards = get_boards.sync(client=client, organisation_id=self._org)
450+
if isinstance(boards, models.Error) or boards is None:
451+
sys.exit(f"Board query failed {boards}")
452+
options = [f"{b.name:20s} ({b.id})" for b in boards]
453+
454+
idx, _val = choose_one("Board", options)
455+
self._board = boards[idx].id
456+
self._board_name = boards[idx].name
457+
else:
458+
board = get_board_by_id.sync(client=client, id=self._board)
459+
if not isinstance(org, models.Board):
460+
sys.exit(f"Failed to query board for ID {self._board}")
461+
self._board_name = board.name
462+
463+
application = get_application_by_organisation_id_and_application_id.sync(
464+
client=client, id=self._org, application_id=app_id
465+
)
466+
467+
if application is None:
468+
dialog = f"Application 0x{app_id:08x} does not exist in organisation {self.args.org}, create?"
469+
if not user_confirm(dialog):
470+
return
471+
print(f"Creating application 0x{app_id:08x} in organisation {self.args.org}")
472+
description = user_response("Application description:")
473+
body = models.NewApplication(id=app_id, name=name, description=description)
474+
application = create_application.sync(client=client, id=self._org, body=body)
475+
476+
if not isinstance(application, models.Application):
477+
sys.exit(f"Unexpected internal type {type(application)}")
478+
479+
ota_files = glob.glob(str(release.dir / "ota-*.bin"))
480+
if len(ota_files) != 1:
481+
sys.exit(f"Unexpected OTA file search result {ota_files}")
482+
483+
dialog = (
484+
f"Create release for application '0x{app_id:08x} {str(version)}'"
485+
+ f" in organisation '{self._org_name}' for board '{self._board_name}'?"
486+
)
487+
if not user_confirm(dialog):
488+
return
489+
490+
with open(ota_files[0], "rb") as f:
491+
ota_file = File(f, ota_files[0], None)
492+
493+
release_obj = models.CreateReleaseBody(
494+
file=ota_file,
495+
file_diff_len=str(0),
496+
version_major=str(version.major),
497+
version_minor=str(version.minor),
498+
version_revision=str(version.revision),
499+
version_build_num=str(version.build_num),
500+
board_id=self._board,
501+
board_target=board_target,
502+
)
503+
504+
rsp = create_release.sync(
505+
client=client,
506+
id=self._org,
507+
application_id=app_id,
508+
body=release_obj,
509+
)
510+
if rsp is None:
511+
sys.exit("Create release: No response")
512+
elif isinstance(rsp, models.Error):
513+
sys.exit(f"<{rsp.code}>: {rsp.message}")
514+
else:
515+
print(f"Release created with ID '{rsp.id}'")
516+
517+
325518
class SubCommand(InfuseCommand):
326519
NAME = "cloud"
327520
HELP = "Infuse-IoT cloud interaction"
@@ -335,6 +528,7 @@ def add_parser(cls, parser):
335528
Boards.add_parser(subparser)
336529
Device.add_parser(subparser)
337530
Coap.add_parser(subparser)
531+
Applications.add_parser(subparser)
338532

339533
def __init__(self, args):
340534
self.tool = args.command_class(args)

0 commit comments

Comments
 (0)