esp-morpheus 0.9.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (72) hide show
  1. esp_morpheus/__init__.py +7 -0
  2. esp_morpheus/cli/__init__.py +3 -0
  3. esp_morpheus/cli/admin/__init__.py +173 -0
  4. esp_morpheus/cli/admin/botuser.py +212 -0
  5. esp_morpheus/cli/admin/gendevice.py +61 -0
  6. esp_morpheus/cli/admin/integrations.py +211 -0
  7. esp_morpheus/cli/admin/messaging.py +156 -0
  8. esp_morpheus/cli/admin/nodes.py +71 -0
  9. esp_morpheus/cli/admin/platforms.py +118 -0
  10. esp_morpheus/cli/admin/runtime.py +74 -0
  11. esp_morpheus/cli/admin/testdata.py +375 -0
  12. esp_morpheus/cli/context.py +399 -0
  13. esp_morpheus/cli/device.py +126 -0
  14. esp_morpheus/cli/groups.py +251 -0
  15. esp_morpheus/cli/guide.py +155 -0
  16. esp_morpheus/cli/main.py +176 -0
  17. esp_morpheus/cli/matter.py +139 -0
  18. esp_morpheus/cli/nodes.py +106 -0
  19. esp_morpheus/cli/output.py +360 -0
  20. esp_morpheus/cli/sharing.py +93 -0
  21. esp_morpheus/cli/shell.py +229 -0
  22. esp_morpheus/cli/sims.py +263 -0
  23. esp_morpheus/cli/user.py +191 -0
  24. esp_morpheus/data/examples/alexa_skills_config.example.json +5 -0
  25. esp_morpheus/data/examples/bot-iam-user-credentials.example.json +5 -0
  26. esp_morpheus/data/examples/gva_config.example.json +13 -0
  27. esp_morpheus/data/node_config_va_light.json +25 -0
  28. esp_morpheus/data/node_config_va_multi.json +264 -0
  29. esp_morpheus/data/node_config_va_switch.json +19 -0
  30. esp_morpheus/data/node_tags.json +11 -0
  31. esp_morpheus/data/test_config.default.json +48 -0
  32. esp_morpheus/outputs.py +292 -0
  33. esp_morpheus/paths.py +135 -0
  34. esp_morpheus/sdk/__init__.py +3 -0
  35. esp_morpheus/sdk/alexa_setup.py +645 -0
  36. esp_morpheus/sdk/device.py +1640 -0
  37. esp_morpheus/sdk/errors.py +92 -0
  38. esp_morpheus/sdk/group.py +269 -0
  39. esp_morpheus/sdk/gva_setup.py +233 -0
  40. esp_morpheus/sdk/kvs_viewer.py +2029 -0
  41. esp_morpheus/sdk/mailosaur.py +235 -0
  42. esp_morpheus/sdk/matter.py +249 -0
  43. esp_morpheus/sdk/ses.py +110 -0
  44. esp_morpheus/sdk/smartthings.py +122 -0
  45. esp_morpheus/sdk/user.py +3724 -0
  46. esp_morpheus/sdk/util.py +129 -0
  47. esp_morpheus/sims/__init__.py +3 -0
  48. esp_morpheus/sims/app.py +1473 -0
  49. esp_morpheus/sims/chal_resp_proto/__init__.py +0 -0
  50. esp_morpheus/sims/chal_resp_proto/esp_rmaker_chal_resp.proto +61 -0
  51. esp_morpheus/sims/chal_resp_proto/esp_rmaker_chal_resp_pb2.py +52 -0
  52. esp_morpheus/sims/chal_resp_proto/esp_rmaker_claim.proto +45 -0
  53. esp_morpheus/sims/chal_resp_proto/esp_rmaker_claim_pb2.py +44 -0
  54. esp_morpheus/sims/device.py +666 -0
  55. esp_morpheus/sims/idf_protocomm/components/protocomm/proto/constants.proto +18 -0
  56. esp_morpheus/sims/idf_protocomm/components/protocomm/proto/sec0.proto +32 -0
  57. esp_morpheus/sims/idf_protocomm/components/protocomm/proto/sec1.proto +49 -0
  58. esp_morpheus/sims/idf_protocomm/components/protocomm/proto/sec2.proto +52 -0
  59. esp_morpheus/sims/idf_protocomm/components/protocomm/proto/session.proto +29 -0
  60. esp_morpheus/sims/idf_protocomm/components/protocomm/python/constants_pb2.py +36 -0
  61. esp_morpheus/sims/idf_protocomm/components/protocomm/python/sec0_pb2.py +43 -0
  62. esp_morpheus/sims/idf_protocomm/components/protocomm/python/sec1_pb2.py +47 -0
  63. esp_morpheus/sims/idf_protocomm/components/protocomm/python/sec2_pb2.py +47 -0
  64. esp_morpheus/sims/idf_protocomm/components/protocomm/python/session_pb2.py +41 -0
  65. esp_morpheus/sims/prov_ble.py +336 -0
  66. esp_morpheus/sims/sparse_dep.py +190 -0
  67. esp_morpheus-0.9.0.dist-info/METADATA +434 -0
  68. esp_morpheus-0.9.0.dist-info/RECORD +72 -0
  69. esp_morpheus-0.9.0.dist-info/WHEEL +5 -0
  70. esp_morpheus-0.9.0.dist-info/entry_points.txt +2 -0
  71. esp_morpheus-0.9.0.dist-info/licenses/LICENSE +202 -0
  72. esp_morpheus-0.9.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,7 @@
1
+ # SPDX-FileCopyrightText: 2024-2026 Espressif Systems (Shanghai) CO LTD
2
+ #
3
+ # SPDX-License-Identifier: Apache-2.0
4
+
5
+ """esp-morpheus: the console client for an ESP RainMaker Neo deployment."""
6
+
7
+ __version__ = '0.9.0'
@@ -0,0 +1,3 @@
1
+ # SPDX-FileCopyrightText: 2024-2026 Espressif Systems (Shanghai) CO LTD
2
+ #
3
+ # SPDX-License-Identifier: Apache-2.0
@@ -0,0 +1,173 @@
1
+ # SPDX-FileCopyrightText: 2024-2026 Espressif Systems (Shanghai) CO LTD
2
+ #
3
+ # SPDX-License-Identifier: Apache-2.0
4
+
5
+ """`esp-morpheus admin ...` — every operation that needs AWS credentials or the admin API.
6
+
7
+ One tree holds the whole privileged surface, so what an ordinary caller can reach is exactly what
8
+ sits outside it: `user` and `device` go through the deployment's own API and broker and need no AWS
9
+ account, while everything here either signs in against the admin pool or calls AWS directly.
10
+
11
+ The identity is optional, because half of these commands need no identity at all — `ses`, `sns`,
12
+ `bot-user` and `test-data` reach AWS, not the admin API.
13
+ """
14
+
15
+ import os
16
+
17
+ import boto3
18
+ import click
19
+ from botocore.exceptions import BotoCoreError, ClientError
20
+
21
+ from .. import output
22
+ from ..context import Session
23
+ from ..guide import guide
24
+ from ..shell import ContextGroup
25
+ from . import (
26
+ botuser,
27
+ gendevice,
28
+ integrations,
29
+ messaging,
30
+ nodes,
31
+ platforms,
32
+ runtime,
33
+ testdata,
34
+ )
35
+
36
+ IDENTITY_HINT = ('name one with `esp-morpheus admin <identity>`, or seed one with '
37
+ '`esp-morpheus admin test-data setup`.')
38
+
39
+ # Where the group leaves the identity it took off the command line, for its own callback to read.
40
+ IDENTITY_META = 'morpheus.admin.identity'
41
+
42
+
43
+ def _configured_admin(session):
44
+ """The admin test_config.json flags, so the seeded deployment needs no identity."""
45
+ return next((u.get('name') for u in session.config.get('users', []) if u.get('admin')), None)
46
+
47
+
48
+ def _deployment_row(session, settings):
49
+ """Which deployment every command here acts on."""
50
+ if settings is None:
51
+ return '[red]unresolved[/red]', session.settings_error
52
+ return f"{settings.account_id} / {settings.region}", _source_label(settings.source)
53
+
54
+
55
+ def _source_label(source):
56
+ """The outputs file, written relative to the working directory when it sits below it."""
57
+ if source.startswith(('http://', 'https://')):
58
+ return source
59
+ relative = os.path.relpath(source)
60
+ return source if relative.startswith(os.pardir) else relative
61
+
62
+
63
+ def _admin_api_row(session, identity, settings):
64
+ """The admin account as (value, note, username), the last being what the prompt is labelled with.
65
+
66
+ Sign-in is reported, not enforced: the AWS half of this tree runs without an admin account.
67
+ """
68
+ if not identity:
69
+ return '[yellow]none[/yellow]', IDENTITY_HINT, None
70
+ if settings is None:
71
+ return identity, 'not signed in: the deployment is unresolved', None
72
+ # Warn rather than abort: an admin that does not exist yet is created by `test-data setup`.
73
+ user = session.authenticated_user(on_failure='warn')
74
+ note = 'signed in' if getattr(user, 'token', None) else '[red]sign-in failed[/red]'
75
+ return f"[bold]{user.username}[/bold]", note, user.username
76
+
77
+
78
+ def _aws_row():
79
+ """Where the ambient credentials come from, without spending an STS call to find out.
80
+
81
+ Resolving them for real would force credentials on a prompt whose admin-API half needs none.
82
+ """
83
+ try:
84
+ credentials = boto3.session.Session().get_credentials()
85
+ except (BotoCoreError, ClientError) as e:
86
+ return '[red]unusable[/red]', str(e)
87
+ if credentials is None:
88
+ return ('[yellow]none found[/yellow]',
89
+ 'configure AWS_PROFILE, `aws sso login`, ~/.aws/credentials or the environment')
90
+ profile = os.environ.get('AWS_PROFILE')
91
+ source = getattr(credentials, 'method', None) or 'unknown source'
92
+ where = f"{source}, AWS_PROFILE={profile}" if profile else source
93
+ return where, 'account checked on the first AWS command'
94
+
95
+
96
+ def _print_context(session, identity):
97
+ """Name both identities this context holds, and return the one the prompt is labelled with.
98
+
99
+ Every row is built before anything prints, so a password prompt cannot land inside the banner.
100
+ """
101
+ settings = session.try_settings()
102
+ api_value, api_note, username = _admin_api_row(session, identity, settings)
103
+ output.context_rows('Admin context', [
104
+ ('deployment', *_deployment_row(session, settings)),
105
+ ('admin API', api_value, api_note),
106
+ ('AWS creds', *_aws_row()),
107
+ ])
108
+ return username
109
+
110
+
111
+ class AdminGroup(ContextGroup):
112
+ """`admin [IDENTITY] <command>`, where the identity may be left out.
113
+
114
+ click would read the first word as the identity and then find no command, so a first word that
115
+ names one of these commands is that command.
116
+ """
117
+
118
+ def parse_args(self, ctx, args):
119
+ if args and not args[0].startswith('-') and args[0] not in self.commands:
120
+ ctx.meta[IDENTITY_META] = args[0]
121
+ args = args[1:]
122
+ return super().parse_args(ctx, args)
123
+
124
+ def resolve_command(self, ctx, args):
125
+ """Report a word taken as the identity, which is what a mistyped command becomes here."""
126
+ try:
127
+ return super().resolve_command(ctx, args)
128
+ except click.UsageError:
129
+ identity = ctx.meta.get(IDENTITY_META)
130
+ if not identity:
131
+ raise
132
+ raise click.UsageError(
133
+ f"No such command {args[0]!r}. The first word is read as the identity "
134
+ f"({identity!r}), so the command follows it.", ctx=ctx) from None
135
+
136
+
137
+ @click.group('admin', cls=AdminGroup, invoke_without_command=True,
138
+ options_metavar='', subcommand_metavar='[IDENTITY] [OPTIONS] COMMAND [ARGS]...',
139
+ repl_prompt='{label} > ', history='admin')
140
+ @click.option('--password', help='Password for an identity that test_config.json does not carry. '
141
+ 'Also read from RMNG_PASSWORD, else prompted for. Prefer either '
142
+ 'over argv, which other processes and shell history can see.')
143
+ @click.pass_context
144
+ def admin(ctx, password):
145
+ """Administer the deployment, as IDENTITY where a command needs one.
146
+
147
+ IDENTITY is an admin: an email address, or an index or name in test_config.json. It defaults to
148
+ the admin test_config.json flags, and only the commands that call the admin API need one. With
149
+ no subcommand, this opens an interactive prompt.
150
+ """
151
+ session = ctx.find_object(Session)
152
+ session.is_admin = True
153
+ session.password = password
154
+ session.identity_hint = IDENTITY_HINT
155
+
156
+ identity = ctx.meta.get(IDENTITY_META) or _configured_admin(session)
157
+ if identity:
158
+ session.select_user(identity)
159
+ if ctx.invoked_subcommand is not None:
160
+ return
161
+
162
+ username = _print_context(session, identity)
163
+ ctx.command.open_shell(ctx, f"admin:{username}" if username else 'admin')
164
+
165
+
166
+ # `guide` needs nothing itself: it prints the console half of a setup whose other half is here.
167
+ for command in (botuser.bot_user, guide, integrations.integrations, messaging.ses, messaging.sns,
168
+ nodes.nodes, platforms.platforms, runtime.claiming, runtime.iot_event_mode,
169
+ testdata.test_data):
170
+ admin.add_command(command)
171
+
172
+ # Same reason, one level down: the node it writes is inert until `test-data setup` registers it.
173
+ testdata.test_data.add_command(gendevice.gen_device)
@@ -0,0 +1,212 @@
1
+ # SPDX-FileCopyrightText: 2024-2026 Espressif Systems (Shanghai) CO LTD
2
+ #
3
+ # SPDX-License-Identifier: Apache-2.0
4
+
5
+ """`esp-morpheus admin bot-user ...` — the CI IAM user and its access keys."""
6
+
7
+ import json
8
+
9
+ import boto3
10
+ import click
11
+ from botocore.exceptions import ClientError
12
+
13
+ from ... import paths
14
+ from .. import output
15
+ from ..context import pass_session
16
+
17
+ ADMINISTRATOR_ACCESS_POLICY_ARN = 'arn:aws:iam::aws:policy/AdministratorAccess'
18
+
19
+ # Name of the IAM user when nothing names one. The key holding a chosen name is written by
20
+ # `bot-user create`, so no entry has to exist before the first run.
21
+ DEFAULT_BOT_USER_NAME = 'bot'
22
+ BOT_USER_CONFIG_KEY = 'ci_bot_user'
23
+
24
+
25
+ @click.group('bot-user')
26
+ def bot_user():
27
+ """Create and delete the CI IAM bot user.
28
+
29
+ One bot at a time: there is a single credentials file, so a second user under another name would
30
+ overwrite the keys of the first and leave it in IAM with nothing tracking its access key.
31
+ """
32
+
33
+
34
+ def _credentials_owner():
35
+ """The user named by the credentials file, or None when there is no file and no usable name."""
36
+ path = paths.bot_credentials_path()
37
+ if not path.is_file():
38
+ return None
39
+ try:
40
+ with open(path, encoding='utf-8') as handle:
41
+ return json.load(handle).get('user_name') or None
42
+ except (OSError, ValueError):
43
+ return None
44
+
45
+
46
+ def _tracked_bot_user_name(session):
47
+ """The bot this checkout already holds keys for, or None.
48
+
49
+ The credentials file answers first: it is the record that a create would overwrite.
50
+ """
51
+ return _credentials_owner() or session.config.get(BOT_USER_CONFIG_KEY) or None
52
+
53
+
54
+ def _bot_user_name(session, name=None):
55
+ return name or _tracked_bot_user_name(session) or DEFAULT_BOT_USER_NAME
56
+
57
+
58
+ def _remember_bot_user_name(session, name):
59
+ """Record the name so `delete` and `show` need no argument."""
60
+ if session.config.get(BOT_USER_CONFIG_KEY) == name:
61
+ return
62
+ session.config[BOT_USER_CONFIG_KEY] = name
63
+ session.write_config()
64
+
65
+
66
+ def _forget_bot_user_name(session, name):
67
+ if session.config.get(BOT_USER_CONFIG_KEY) != name:
68
+ return
69
+ del session.config[BOT_USER_CONFIG_KEY]
70
+ session.write_config()
71
+
72
+
73
+ def _iam_user_exists(iam, user_name):
74
+ try:
75
+ iam.get_user(UserName=user_name)
76
+ return True
77
+ except ClientError as e:
78
+ if e.response.get('Error', {}).get('Code') == 'NoSuchEntity':
79
+ return False
80
+ raise
81
+
82
+
83
+ def _delete_access_keys(iam, name):
84
+ for page in iam.get_paginator('list_access_keys').paginate(UserName=name):
85
+ for meta in page.get('AccessKeyMetadata', []):
86
+ iam.delete_access_key(UserName=name, AccessKeyId=meta['AccessKeyId'])
87
+
88
+
89
+ def _write_bot_credentials(name, key):
90
+ credentials_path = paths.bot_credentials_path()
91
+ paths.ensure(credentials_path.parent)
92
+ with open(credentials_path, 'w', encoding='utf-8') as handle:
93
+ json.dump({'user_name': name,
94
+ 'access_key_id': key['AccessKeyId'],
95
+ 'secret_access_key': key['SecretAccessKey']}, handle, indent=2)
96
+ try:
97
+ credentials_path.chmod(0o600)
98
+ except OSError:
99
+ pass
100
+ return credentials_path
101
+
102
+
103
+ @bot_user.command('show')
104
+ @click.argument('name', required=False)
105
+ @pass_session
106
+ def show_bot_user(session, name):
107
+ """Report the bot user's name, its IAM state and where its keys are."""
108
+ settings = session.require_aws()
109
+ name = _bot_user_name(session, name)
110
+ credentials_path = paths.bot_credentials_path()
111
+ iam = boto3.client('iam', region_name=settings.region)
112
+
113
+ tracked = _tracked_bot_user_name(session)
114
+ record = {'name': name, 'exists': _iam_user_exists(iam, name),
115
+ 'credentials': str(credentials_path),
116
+ 'credentials_present': credentials_path.is_file()}
117
+ if tracked and tracked != name:
118
+ # The keys on disk belong to somebody else, so they are not this user's.
119
+ record['credentials_belong_to'] = tracked
120
+ if record['exists']:
121
+ policies = iam.list_attached_user_policies(UserName=name).get('AttachedPolicies', [])
122
+ record['policies'] = [p['PolicyArn'] for p in policies] or ['(none)']
123
+ record['access_keys'] = sum(
124
+ len(page.get('AccessKeyMetadata', []))
125
+ for page in iam.get_paginator('list_access_keys').paginate(UserName=name))
126
+ output.emit_kv(None, record)
127
+
128
+
129
+ @bot_user.command('create')
130
+ @click.argument('name', required=False)
131
+ @click.option('--rotate', is_flag=True,
132
+ help='Replace the keys of an existing user instead of failing.')
133
+ @pass_session
134
+ def create_bot_user(session, name, rotate):
135
+ """Create the IAM bot user with AdministratorAccess and write its access keys.
136
+
137
+ NAME defaults to the bot this checkout already tracks, then to "bot". The name used is saved
138
+ back to test_config.json, so `bot-user delete` needs no argument.
139
+ """
140
+ settings = session.require_aws()
141
+ name = _bot_user_name(session, name)
142
+ iam = boto3.client('iam', region_name=settings.region)
143
+ tracked = _tracked_bot_user_name(session)
144
+
145
+ # A second name would overwrite the one credentials file and strand the first user in IAM with
146
+ # a live AdministratorAccess key. A tracked name IAM no longer has is stale, so it does not bite.
147
+ if tracked and tracked != name and _iam_user_exists(iam, tracked):
148
+ output.fail(f"This checkout already tracks IAM user {tracked!r}, and there is one "
149
+ f"credentials file. Run `esp-morpheus admin bot-user delete` before creating "
150
+ f"{name!r}, or `esp-morpheus admin bot-user create {tracked} --rotate` to issue it "
151
+ 'a new key.')
152
+
153
+ exists = _iam_user_exists(iam, name)
154
+ if exists and not rotate:
155
+ output.fail(f"IAM user {name!r} already exists. Run `esp-morpheus admin bot-user create "
156
+ '--rotate` to issue a new key, or `esp-morpheus admin bot-user delete` to remove '
157
+ 'the user.')
158
+ if exists:
159
+ # A rotate is the recovery path for a lost credentials file: IAM allows two keys per user,
160
+ # so clear the old ones before asking for another.
161
+ _delete_access_keys(iam, name)
162
+ else:
163
+ iam.create_user(UserName=name, Tags=[{'Key': 'Created-For', 'Value': 'Jenkins+CI'},
164
+ {'Key': 'Created-By', 'Value': 'Repository'}])
165
+ iam.attach_user_policy(UserName=name, PolicyArn=ADMINISTRATOR_ACCESS_POLICY_ARN)
166
+ key = iam.create_access_key(UserName=name)['AccessKey']
167
+
168
+ credentials_path = _write_bot_credentials(name, key)
169
+ _remember_bot_user_name(session, name)
170
+ output.ok(f"{'Rotated the key of' if exists else 'Created'} IAM user {name} with "
171
+ 'AdministratorAccess')
172
+ output.emit_kv(None, {'credentials': str(credentials_path)})
173
+
174
+
175
+ @bot_user.command('delete')
176
+ @click.argument('name', required=False)
177
+ @pass_session
178
+ def delete_bot_user(session, name):
179
+ """Delete the IAM bot user, its access keys and the credentials file."""
180
+ settings = session.require_aws()
181
+ name = _bot_user_name(session, name)
182
+ credentials_path = paths.bot_credentials_path()
183
+ iam = boto3.client('iam', region_name=settings.region)
184
+
185
+ # The file is another bot's only when it names a different user. One naming nobody is unusable,
186
+ # so this command is the last chance to clear it.
187
+ owner = _credentials_owner()
188
+ owns_credentials = owner is None or owner == name
189
+
190
+ if not _iam_user_exists(iam, name):
191
+ output.info(f"IAM user {name!r} does not exist; nothing to destroy.")
192
+ if owns_credentials and credentials_path.is_file():
193
+ credentials_path.unlink()
194
+ output.ok(f"Removed {credentials_path}")
195
+ _forget_bot_user_name(session, name)
196
+ return
197
+
198
+ _delete_access_keys(iam, name)
199
+
200
+ try:
201
+ iam.detach_user_policy(UserName=name, PolicyArn=ADMINISTRATOR_ACCESS_POLICY_ARN)
202
+ except ClientError as e:
203
+ if e.response.get('Error', {}).get('Code') != 'NoSuchEntity':
204
+ raise
205
+
206
+ iam.delete_user(UserName=name)
207
+ _forget_bot_user_name(session, name)
208
+ if owns_credentials and credentials_path.is_file():
209
+ credentials_path.unlink()
210
+ output.ok(f"Deleted IAM user {name} and removed {credentials_path}")
211
+ return
212
+ output.ok(f"Deleted IAM user {name}")
@@ -0,0 +1,61 @@
1
+ # SPDX-FileCopyrightText: 2024-2026 Espressif Systems (Shanghai) CO LTD
2
+ #
3
+ # SPDX-License-Identifier: Apache-2.0
4
+
5
+ """`esp-morpheus admin test-data gen-device` — a certificate and key for a node, generated locally.
6
+
7
+ It reaches nothing and needs no credentials. It sits with `test-data` because it owns the same
8
+ test_config.json, and the node it writes there does nothing until `test-data setup` registers it.
9
+ """
10
+
11
+ import click
12
+
13
+ from ...sdk.device import generate_key_and_cert
14
+ from .. import output
15
+ from ..context import pass_session
16
+
17
+
18
+ @click.command('gen-device')
19
+ @click.argument('node_name')
20
+ @click.argument('key_type', type=click.Choice(['rsa', 'ec']))
21
+ @click.option('--stdout', 'to_stdout', is_flag=True,
22
+ help='Print the certificate and key instead of writing test_config.json.')
23
+ @click.option('--force', is_flag=True,
24
+ help='Replace the certificate when NODE_NAME is already in test_config.json.')
25
+ @pass_session
26
+ def gen_device(session, node_name, key_type, to_stdout, force):
27
+ """Generate a self-signed certificate and key for NODE_NAME.
28
+
29
+ Needs no deployment and no credentials. The node is appended to test_config.json, which is
30
+ written from the packaged defaults first when it does not exist yet. Register it with
31
+ `esp-morpheus admin test-data setup`, which re-runs idempotently over the nodes already seeded.
32
+ """
33
+ key_pem, cert_pem = generate_key_and_cert(node_name, key_type)
34
+ entry = {'thing_name': node_name, 'cert': cert_pem, 'key': key_pem}
35
+
36
+ if to_stdout:
37
+ output.emit_kv(None, entry)
38
+ return
39
+
40
+ if not session.config_exists():
41
+ session.generate_config()
42
+
43
+ nodes = session.config.setdefault('nodes', [])
44
+ index = next((i for i, n in enumerate(nodes) if n.get('thing_name') == node_name), None)
45
+ if index is None:
46
+ nodes.append(entry)
47
+ index = len(nodes) - 1
48
+ action = 'Added'
49
+ elif force:
50
+ # Updated, not replaced, so node_cfg and associate_to survive a certificate regeneration.
51
+ nodes[index].update(entry)
52
+ action = 'Regenerated the certificate of'
53
+ else:
54
+ output.fail(f"{node_name} is already node {index} in test_config.json. Pass --force to "
55
+ 'regenerate its certificate, which breaks the deployed node until the next '
56
+ '`admin test-data setup`.')
57
+
58
+ path = session.write_config()
59
+ output.ok(f"{action} {node_name} as node {index}")
60
+ output.emit_kv(None, {'thing_name': node_name, 'key_type': key_type,
61
+ 'index': index, 'config': str(path)})
@@ -0,0 +1,211 @@
1
+ # SPDX-FileCopyrightText: 2024-2026 Espressif Systems (Shanghai) CO LTD
2
+ #
3
+ # SPDX-License-Identifier: Apache-2.0
4
+
5
+ """`esp-morpheus admin integrations ...` — the voice-assistant credentials each skill needs."""
6
+
7
+ import os
8
+
9
+ import click
10
+
11
+ from ...sdk import alexa_setup as alexa_smapi, gva_setup as gva_gcp
12
+ from .. import output
13
+ from ..context import Session, pass_session, pass_user
14
+ from ..guide import print_alexa_instructions
15
+
16
+ DEFAULT_ALEXA_CONFIG = 'alexa_skills_config.json'
17
+ ALEXA_CONFIG_KEYS = ('smapi_client_id', 'smapi_client_secret', 'vendor_id',
18
+ 'smapi_refresh_token', 'redirect_urls', 'skill_name', 'skill_id')
19
+
20
+
21
+ def _check_alexa_config(ctx, param, value):
22
+ """Reject a missing config before the command logs in, naming the keys the file needs."""
23
+ del ctx, param
24
+ if not os.path.isfile(value):
25
+ raise click.UsageError(
26
+ f'Alexa config not found: {value}\n'
27
+ f"Pass --config PATH, or write the file with these keys: "
28
+ f"{', '.join(ALEXA_CONFIG_KEYS)}")
29
+ return value
30
+
31
+
32
+ def alexa_config_option(command):
33
+ """Attach the --config option every SMAPI command reads."""
34
+ return click.option(
35
+ '--config', 'config_file', default=DEFAULT_ALEXA_CONFIG, show_default=True,
36
+ type=click.Path(dir_okay=False), callback=_check_alexa_config, is_eager=True,
37
+ help='JSON file with the SMAPI credentials: ' + ', '.join(ALEXA_CONFIG_KEYS) + '.',
38
+ )(command)
39
+
40
+
41
+ @click.group('integrations')
42
+ def integrations():
43
+ """Configure the voice-assistant integrations."""
44
+
45
+
46
+ @integrations.group('alexa')
47
+ def alexa():
48
+ """Alexa smart-home skill configuration.
49
+
50
+ The SMAPI commands (setup-auto, list-skills, delete-skill) take `--config FILE`, which defaults
51
+ to alexa_skills_config.json. The setup command takes its own file as an argument.
52
+ """
53
+
54
+
55
+ def _alexa_config_to_env(session, config_file):
56
+ """Populate the env the SMAPI helper reads, so one config file drives both halves.
57
+
58
+ Example alexa_skills_config.json:
59
+ {"smapi_client_id": "...", "smapi_client_secret": "...",
60
+ "skill_name": "ESP RainMaker", "skill_id": "amzn1.ask.skill.xxxx"}
61
+ """
62
+ config = output.read_json_file(config_file, 'Alexa config')
63
+ env_map = {
64
+ 'SMAPI_CLIENT_ID': 'smapi_client_id',
65
+ 'SMAPI_CLIENT_SECRET': 'smapi_client_secret',
66
+ 'SMAPI_VENDOR_ID': 'vendor_id',
67
+ 'SMAPI_REFRESH_TOKEN': 'smapi_refresh_token',
68
+ 'ALEXA_REDIRECT_URLS': 'redirect_urls',
69
+ }
70
+ for env_key, config_key in env_map.items():
71
+ value = config.get(config_key)
72
+ if value:
73
+ os.environ[env_key] = ','.join(value) if isinstance(value, list) else str(value)
74
+ # alexa_setup reads outputs itself, keyed off RMNG_OUTPUTS; point it at the source esp-morpheus
75
+ # resolved so --client-outputs applies there too.
76
+ os.environ['RMNG_OUTPUTS'] = session.settings.source
77
+ return config
78
+
79
+
80
+ @alexa.command('setup')
81
+ @click.argument('config_file', type=click.Path(exists=True, dir_okay=False))
82
+ @click.pass_context
83
+ def alexa_config(ctx, config_file):
84
+ """Store the Alexa skill credentials from CONFIG_FILE in the backend."""
85
+ session = ctx.find_object(Session)
86
+ config = output.read_json_file(config_file, 'Alexa config')
87
+ missing = [k for k in ('redirect_urls', 'alexa_client_id', 'alexa_client_secret', 'skill_id')
88
+ if not config.get(k)]
89
+ if missing:
90
+ output.fail(f"{config_file} is missing {', '.join(missing)}")
91
+
92
+ output.emit_response(session.user.alexa_post_configuration(
93
+ redirect_uris=config['redirect_urls'], client_id=config['alexa_client_id'],
94
+ client_secret=config['alexa_client_secret'], skill_id=config['skill_id']),
95
+ 'Alexa configuration stored')
96
+ print_alexa_instructions(session.settings)
97
+
98
+
99
+ @alexa.command('setup-auto')
100
+ @alexa_config_option
101
+ @click.argument('skill_name', nargs=-1)
102
+ @click.pass_context
103
+ def alexa_auto(ctx, config_file, skill_name):
104
+ """Create or update the skill over SMAPI, then store its configuration.
105
+
106
+ The config POST goes through this admin's session, so no separate AWS credentials are needed.
107
+ """
108
+ session = ctx.find_object(Session)
109
+ config = _alexa_config_to_env(session, config_file)
110
+
111
+ def post_config(skill_id, client_id, client_secret, redirect_uris):
112
+ output.emit_response(session.user.alexa_post_configuration(
113
+ redirect_uris=redirect_uris, client_id=client_id,
114
+ client_secret=client_secret, skill_id=skill_id), 'Alexa configuration stored')
115
+
116
+ alexa_smapi.setup(post_config, skill_id=config.get('skill_id'),
117
+ skill_name=' '.join(skill_name) or config.get('skill_name'))
118
+
119
+
120
+ @alexa.command('list-skills')
121
+ @alexa_config_option
122
+ @pass_session
123
+ def alexa_list(session, config_file):
124
+ """List every Alexa skill under the vendor."""
125
+ _alexa_config_to_env(session, config_file)
126
+ alexa_smapi.list_all()
127
+
128
+
129
+ @alexa.command('delete-skill')
130
+ @click.argument('skill_id')
131
+ @alexa_config_option
132
+ @pass_session
133
+ def alexa_delete(session, skill_id, config_file):
134
+ """Delete the Alexa skill SKILL_ID."""
135
+ _alexa_config_to_env(session, config_file)
136
+ alexa_smapi.delete(skill_id)
137
+
138
+
139
+ @integrations.group('gva')
140
+ def gva():
141
+ """Google Home integration."""
142
+
143
+
144
+ @gva.command('setup')
145
+ @click.argument('service_account_json', type=click.Path(exists=True, dir_okay=False))
146
+ @click.pass_context
147
+ def gva_setup(ctx, service_account_json):
148
+ """Upload the HomeGraph service-account key in SERVICE_ACCOUNT_JSON."""
149
+ session = ctx.find_object(Session)
150
+ config = output.read_json_file(service_account_json, 'service account')
151
+ output.emit_response(session.user.gva_post_configuration(config),
152
+ 'Google Home configuration stored')
153
+
154
+
155
+ @gva.command('setup-auto')
156
+ @click.argument('project_id')
157
+ @click.argument('service_account_name', required=False)
158
+ @pass_user
159
+ def gva_auto(user, project_id, service_account_name):
160
+ """Enable the HomeGraph API, create the report-state service account and key on PROJECT_ID,
161
+ then store the key.
162
+
163
+ Replaces the console procedure in the spec (enable API, create account, grant the OpenID
164
+ Connect Identity Token Creator role, download a key, re-upload it). The Google-side work runs
165
+ locally against your `gcloud` credentials; only the config POST goes through this admin's
166
+ session, so no separate AWS credentials are needed.
167
+
168
+ The Google Home Developer Console part (creating the Home project and the cloud-to-cloud
169
+ integration) has no public API and stays manual.
170
+ """
171
+ def post_config(service_account_json):
172
+ output.emit_response(user.gva_post_configuration(service_account_json),
173
+ 'Google Home configuration stored')
174
+
175
+ gva_gcp.setup(post_config, project_id, service_account_name)
176
+
177
+
178
+ @integrations.group('smartthings')
179
+ def smartthings():
180
+ """SmartThings Schema App integration."""
181
+
182
+
183
+ @smartthings.command('setup')
184
+ @click.argument('config_file', type=click.Path(exists=True, dir_okay=False))
185
+ @pass_user
186
+ def st_setup(user, config_file):
187
+ """Store the credentials SmartThings issued, read from CONFIG_FILE.
188
+
189
+ Read from a file rather than argv: the client secret is 512 characters, and a secret in argv is
190
+ visible to other processes and lands in shell history.
191
+ """
192
+ config = output.read_json_file(config_file, 'SmartThings config')
193
+ missing = [field for field in ('client_id', 'client_secret') if not config.get(field)]
194
+ if missing:
195
+ output.fail(f"{config_file} is missing {', '.join(missing)}")
196
+ output.emit_response(user.st_post_configuration(config['client_id'], config['client_secret']),
197
+ 'SmartThings configuration stored')
198
+
199
+
200
+ @smartthings.command('get-config')
201
+ @pass_user
202
+ def st_get(user):
203
+ """Show the stored SmartThings configuration."""
204
+ output.emit_response(user.st_get_configuration())
205
+
206
+
207
+ @smartthings.command('delete-config')
208
+ @pass_user
209
+ def st_delete(user):
210
+ """Delete the stored SmartThings configuration."""
211
+ output.emit_response(user.st_delete_configuration(), 'SmartThings configuration deleted')