55__author__ = "Jordan Yates"
66__copyright__ = "Copyright 2024, Embeint Holdings Pty Ltd"
77
8+ import glob
89import sys
910from typing import Any
11+ from uuid import UUID
1012
1113from tabulate import tabulate
1214
1315import infuse_iot .api_client .models as models
1416from 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+ )
1525from infuse_iot .api_client .api .board import (
1626 create_board ,
1727 get_board_by_id ,
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
3544from infuse_iot .commands import InfuseCommand
3645from 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
3951class 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+
325570class 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