From a942573be40ecdd24f4a6b8da795845c0a40d228 Mon Sep 17 00:00:00 2001 From: Khyati Mahendru Date: Mon, 28 Sep 2026 08:57:05 +0000 Subject: [PATCH 1/2] Add upload and download channels --- .gencode_hash.txt | 52 +- bin/start_mosquitto | 4 + bin/test_mosquitto | 2 + clientlib/python/src/udmi/core/device.py | 163 +- .../src/udmi/core/managers/base_manager.py | 24 + .../core/messaging/mqtt_messaging_client.py | 6 +- .../messaging/test_mqtt_messaging_client.py | 6 +- clientlib/python/tests/core/test_device.py | 59 + docs/messages/readme.md | 9 +- docs/specs/blob_updates.md | 40 + docs/specs/discovery.md | 13 + docs/specs/tech_stack.md | 14 +- docs/tools/spotter.md | 12 +- edge/spotter/README.md | 18 +- edge/spotter/bin/reassemble_pcap | 33 +- edge/spotter/bin/test_fault_injection | 2 +- edge/spotter/bin/test_pcap | 18 +- edge/spotter/src/manager/discovery.py | 52 +- edge/spotter/tests/test_agent.py | 36 +- gencode/docs/config.html | 2 +- gencode/docs/configuration_endpoint.html | 270 + gencode/docs/configuration_execution.html | 624 ++ gencode/docs/configuration_pod.html | 7428 +++++++++++------ gencode/docs/configuration_pubber.html | 312 + gencode/docs/download_blobset.html | 2131 +++++ gencode/docs/events.html | 323 - gencode/docs/monitoring.html | 4 +- gencode/docs/persistent_device.html | 312 + gencode/docs/readme.md | 3 +- ...nts_streams.html => upload_discovery.html} | 132 +- gencode/java/udmi/schema/Auth_provider.java | 5 +- gencode/java/udmi/schema/BlobsetConfig.java | 4 +- gencode/java/udmi/schema/BlobsetDownload.java | 67 + gencode/java/udmi/schema/DiscoveryUpload.java | 123 + .../udmi/schema/EndpointConfiguration.java | 10 +- gencode/java/udmi/schema/Envelope.java | 7 +- gencode/java/udmi/schema/Events.java | 14 +- gencode/java/udmi/schema/Jwt.java | 11 +- gencode/java/udmi/schema/Mtls.java | 55 + gencode/java/udmi/schema/StreamsEvents.java | 95 - gencode/presentation/presentation.json | 62 + gencode/python/udmi/schema/__init__.py | 3 +- gencode/python/udmi/schema/common.py | 2 + .../udmi/schema/configuration_endpoint.py | 10 + .../python/udmi/schema/download_blobset.py | 21 + gencode/python/udmi/schema/envelope.py | 3 +- gencode/python/udmi/schema/events.py | 2 - ...{events_streams.py => upload_discovery.py} | 9 +- misc/bambi/presentation.gld.json | 62 + .../main/java/udmi/lib/base/MqttDevice.java | 2 + .../java/udmi/lib/base/MqttPublisher.java | 2 + .../udmi/lib/client/host/PublisherHost.java | 47 + schema/common.json | 4 +- schema/configuration_endpoint.json | 48 + schema/download_blobset.json | 37 + schema/envelope.json | 7 +- schema/events.json | 3 - schema/events_streams.json | 40 - schema/upload_discovery.json | 62 + tests/schemas/download_blobset/endpoint.json | 30 + tests/schemas/download_blobset/endpoint.out | 0 tests/schemas/envelope/errors1.out | 2 +- tests/schemas/upload_discovery/chunk.json | 15 + tests/schemas/upload_discovery/chunk.out | 0 tests/schemas/upload_discovery/eof.json | 13 + tests/schemas/upload_discovery/eof.out | 0 udmis/etc/local_pod.json | 3 + .../access/DynamicIotAccessProvider.java | 19 + .../udmi/service/access/IotAccessBase.java | 39 + .../service/access/IotAccessProvider.java | 15 + .../access/LocalIotAccessProvider.java | 7 + .../access/ZanzaraIotAccessProvider.java | 42 + .../bos/udmi/service/core/StateProcessor.java | 1 + .../udmi/service/core/TargetProcessor.java | 23 + .../udmi/service/core/UploadProcessor.java | 200 + .../messaging/impl/SimpleMqttPipe.java | 14 +- .../bos/udmi/service/pod/UdmiServicePod.java | 3 +- .../udmi/service/support/MosquittoBroker.java | 40 +- .../access/ZanzaraIotAccessProviderTest.java | 37 + .../udmi/service/core/StateProcessorTest.java | 16 + .../service/core/UploadProcessorTest.java | 198 + .../messaging/impl/SimpleMqttPipeTest.java | 34 + 82 files changed, 10624 insertions(+), 3048 deletions(-) create mode 100644 gencode/docs/download_blobset.html rename gencode/docs/{events_streams.html => upload_discovery.html} (66%) create mode 100644 gencode/java/udmi/schema/BlobsetDownload.java create mode 100644 gencode/java/udmi/schema/DiscoveryUpload.java create mode 100644 gencode/java/udmi/schema/Mtls.java delete mode 100644 gencode/java/udmi/schema/StreamsEvents.java create mode 100644 gencode/python/udmi/schema/download_blobset.py rename gencode/python/udmi/schema/{events_streams.py => upload_discovery.py} (59%) create mode 100644 schema/download_blobset.json delete mode 100644 schema/events_streams.json create mode 100644 schema/upload_discovery.json create mode 100644 tests/schemas/download_blobset/endpoint.json create mode 100644 tests/schemas/download_blobset/endpoint.out create mode 100644 tests/schemas/upload_discovery/chunk.json create mode 100644 tests/schemas/upload_discovery/chunk.out create mode 100644 tests/schemas/upload_discovery/eof.json create mode 100644 tests/schemas/upload_discovery/eof.out create mode 100644 udmis/src/main/java/com/google/bos/udmi/service/core/UploadProcessor.java create mode 100644 udmis/src/test/java/com/google/bos/udmi/service/core/UploadProcessorTest.java diff --git a/.gencode_hash.txt b/.gencode_hash.txt index 004831bf59..ecff870b70 100644 --- a/.gencode_hash.txt +++ b/.gencode_hash.txt @@ -1,33 +1,34 @@ 23553972c4b777768f0f70fc0f89a0ec81f580b64031169a96379ea22bb95e79 gencode/docs/building_config_entity.html c21266e8de23b6e8acfac16a177db782375bf71df470c343c3f8eb8b822ef56a gencode/docs/commands_discovery.html -186cd314b4e72a8666176937168b6f2b5be1da0b08c8e3cbef63dd34573f2a31 gencode/docs/config.html -0dcc1e8245bf0b2791a4bf9be15d8647acd89124ade475345ce4a9c56d62a824 gencode/docs/configuration_endpoint.html -40f078753386e07bfc200eaf3c35cb4362eb413101f4f00d87c88cde3ee53080 gencode/docs/configuration_execution.html -0e2f9b56fd0d3762ff7ce14e0f9fc119cba17a3c2eeb2dcdbf1ff2fad5c6c9d1 gencode/docs/configuration_pod.html -94bb566ac6a4b6b7f89cd2cf1911f658446cc0bea405014275104623f9964dd3 gencode/docs/configuration_pubber.html +b3c03e4c86d7bca5f9b336347fdefb934442d97c666b3af0c358c22d570f704e gencode/docs/config.html +9c73adc77b58e4ebb548d5392393ffb00523f76eaa6eee0618cb51b14f482215 gencode/docs/configuration_endpoint.html +fc091dc5b717ff0c9e8dc0a2c1eb776d5619868d0eeed68582a396c6ad2eaaef gencode/docs/configuration_execution.html +27f25b785f7d8b6e873ba907c4841ad0d493d40235e577b8c966b13b3712954f gencode/docs/configuration_pod.html +21b713fb487fcfad949019cd952116cc15d89c1424df18c39b217712c69768a2 gencode/docs/configuration_pubber.html 1057fa40fb7a31a23bb2773d21c38cf4590a935bd8b5ea4218e695c6204f5dd9 gencode/docs/data_template.html +c89d98aebe040442e619d0374674604c1c04c093d1ba33d751de0a43eda59c7c gencode/docs/download_blobset.html 1d5294b4a1fea6555e23144e594523e4793c795069b6880ce607ad09a6015581 gencode/docs/equipment_translation.html -96dd56a079049801337038709a5b1ca0a8fa7f6ed2df6d46b3de948081e7b203 gencode/docs/events.html +8f078db0e6a109b5b6a05b88e7f95c5cee58675123f7f9aae320a2f46b9ef5fc gencode/docs/events.html 70e57ad6ef39330d958727ebf9dcd61ef6ea30e4c8653eac412bf1867fdb3a70 gencode/docs/events_alarmset.html 1a40d8bfedebe4d636e04ede4e554e98fd7349991511ba72f17fe437128b1ca8 gencode/docs/events_discovery.html eafcc3c48189f605f114cde051fea9d13fc5f1a3e395d64fb0a91cb53d4c9aeb gencode/docs/events_pointset.html -b65ce6e8061e61ba676a72c624f1c425bd238e152ce8e3ebb1e9c23f0e502bcb gencode/docs/events_streams.html 81043ba732caab75704d38e6977b32b1c2bcdad89d94ad2a259d0e70ca4aa40e gencode/docs/events_system.html 46fd37b577c655965346d1e3ececf559c4384791214900eeeea1ad8fc72a02b8 gencode/docs/events_udmi.html baf59590b1a71a49e59d8f499ae5112a1b7a555294dbd72bc821203aecceefa3 gencode/docs/events_validation.html 2f321864fe830aec2679325a1e3dc83fdddace121b6664329f513506904cda35 gencode/docs/metadata.html -18178a591940c176371fb9196048eaacd40f1323997cf907ca11eb879a095c7e gencode/docs/monitoring.html +925dd4b57d66b0a6126d0ca5521e00bd40fc7dd2400976c3d522421607186360 gencode/docs/monitoring.html 64d21cf916e20566171cf9d723e59c297b576bcba89e2a73aec2b018398cc7da gencode/docs/mosquitto_client_response.html -6f332da9cc64230df3551f9748a4eaa4060146e767cae967fc0e73b1f348be5b gencode/docs/persistent_device.html +59e8644f3fd4fbba4e358e08e26fdf832dc4f7ed7e9ece4b409e3c9bd71176a3 gencode/docs/persistent_device.html e11595fd11477947a27461f8ef4fb6facb5f60e2abd6212193f7581ab123ff84 gencode/docs/properties.html a19377370f3cb6621c62e50b4c5e5c61e8c00103cf40c41888199d6c23774466 gencode/docs/query_cloud.html -eb6c623142496b61e3c9b00c9ead0ba8541af2050d76ce652e1721c84aa3a096 gencode/docs/readme.md +15248b985f2edfb19c56cd18d5e5115beba0154c8e2244c2accce646a3a9cc3f gencode/docs/readme.md 2768fd01a875d1f58273e76fef9b421986824d2ab02251853840bca777b625c0 gencode/docs/schema_doc.css 165a29291d7d66653e46fd1ac5868f63e18f9d67c06ea9ebcb95d4830a528e68 gencode/docs/schema_doc.min.js 7ed934930aee763e0beebc349725ba3909115e8d346bb762f28bcbe745bb163a gencode/docs/schema_extras.js 7a1bbe014c739078d354ec484147325e1d0bd19468f275a53da38add43ebe089 gencode/docs/site_metadata.html 0993d21a1a0ae485bdb2f1794f279d13f782e841d07832f795624cc9f158ea93 gencode/docs/state.html 275171b2329d64d497fa54f8877656920be7c4f79bc90f67a0ff1bae6700399b gencode/docs/state_validation.html +7fc32021b42659fea2e69557b4f2a5078b9bb388729dea9b0c9cd96b01eae37a gencode/docs/upload_discovery.html 46347b31bc2a56bc74af9bb702ad40c7823887d5e9b1427903a4892cde3cd169 gencode/java/udmi/schema/AlarmAlarmsetConfig.java 520b12eb8cec4f043f81f898c4be12def09b7de138533922e17c038415fb8902 gencode/java/udmi/schema/AlarmAlarmsetEvents.java 0740aeb4462739e9be7e9c846ebc3f3e6f45f3bb752327c681aa013fbd5c2711 gencode/java/udmi/schema/AlarmAlarmsetModel.java @@ -38,13 +39,14 @@ eb6c623142496b61e3c9b00c9ead0ba8541af2050d76ce652e1721c84aa3a096 gencode/docs/r 2d01271a645b6c8e07f0539c2f73ec2368e7d6125106c7338b3435811fd53f01 gencode/java/udmi/schema/AlarmsetState.java f7a4eb20ea0c074a10e884c95274f4b4284667e46b2049d12db53e4ec5ba1424 gencode/java/udmi/schema/Area.java ceab59904166bc6dcc12de0b78635ed0136ef4e2d5248284df0ec78ece487d83 gencode/java/udmi/schema/Asset.java -53851d3d130fd3d5233c7265cfa83a7ff5839b807515a3160ac487c43315ff09 gencode/java/udmi/schema/Auth_provider.java +20dcabcfd058eb666b4ca00e901672293d620b52e9cb1f9d29a544da4e9e5270 gencode/java/udmi/schema/Auth_provider.java 52c7baba2411cbbc0ab3e2723b1c235cf09ac5977ae9ac1871c179c0e7bbd9d5 gencode/java/udmi/schema/BasePodConfiguration.java a625c62703db143d05b3f6d828d8f0c4f730b506f1b12be12112fc0288f5fa5d gencode/java/udmi/schema/Basic.java 2ee7156c570a0b7ffa0c68c7600b85e1ab2791f77f9aec0ac1d8368fd7adf92e gencode/java/udmi/schema/BlobBlobsetConfig.java 387b682bde5b1b74737f2f05829b192dc9f19cc33f731b4d1ab5198a9510f5b6 gencode/java/udmi/schema/BlobBlobsetState.java c341772caa4c3b73146acac75ce21db66af9097f71a0ab85ff0e06baa4fb2ee9 gencode/java/udmi/schema/BlobUpdateTestingModel.java -378f0771197c3f6d62dbd1384e4159778969cad425a067100e7ec384cebf45e8 gencode/java/udmi/schema/BlobsetConfig.java +ac02d059a25bb9575190d67535e29ee3f0f4e8fefd2fc027872c5d3baca6d5ff gencode/java/udmi/schema/BlobsetConfig.java +92457953c4cee59a66fd0c5cd7ac58467a168484de19a39da4e005794e2eb419 gencode/java/udmi/schema/BlobsetDownload.java cc9d79ed98ab76076be089c4a2bc1e85f6b3f65591548875bf706ee0c2147a72 gencode/java/udmi/schema/BlobsetState.java b4478bfc594cd2ea9631a16b828cdda3de6447263d56fc87e62fbb5a767d05e3 gencode/java/udmi/schema/BridgePodConfiguration.java d9f2924753c34ea5c8c9532d3a94c0c2fe2891d2cc8766646d40b1e90636f372 gencode/java/udmi/schema/Bucket.java @@ -69,12 +71,13 @@ a572f17a61584a32ff19968356f7f7d7bcc8430131ac3701878d0bd221e1a1fa gencode/java/u 9217b2c963003ca51ec61b9aadd40877634f2bea08d7fd412b51521e8ec61cfb gencode/java/udmi/schema/DiscoveryEvents.java af16026c143a592562b77012dd739c02364bd1a9d6bbd6c7ede4f3a5a38b7f4a gencode/java/udmi/schema/DiscoveryModel.java 0fd348086bfd8e38a54a06b0fd205f6d01fdc6236f07ccc8bef49a1832f8c767 gencode/java/udmi/schema/DiscoveryState.java +9bc02795c4c47b16af392b2f0866138cbfe25528b20912ac0fe329c66337c638 gencode/java/udmi/schema/DiscoveryUpload.java baef666f5f58f790b73ba7bed8f60eacdd75ba3151b9abc32265954a9da84faa gencode/java/udmi/schema/Electricity.java -421126685b752587bd2e5f2ea7d7cfa140847500ed8c4f2a7146c9a605f0f2b0 gencode/java/udmi/schema/EndpointConfiguration.java +3c79394af0f0f8d6a9abd97e5fe759d606dc0a66b6b2c0b746c495600807512c gencode/java/udmi/schema/EndpointConfiguration.java 8610ae5a6a32fd17063ce7d488d4546e5ca1fd045c4cc9f0a4b94ad37f58dd60 gencode/java/udmi/schema/Entry.java 4aaacb28a3de0dffeeb8a0bd4352b76b1e9f3279147f8ea303a7fa3d0ff95e0d gencode/java/udmi/schema/Enumerations.java -f890a14567657f5c97c2d3c1c753d751025ee094700ef0659f63f448529cc529 gencode/java/udmi/schema/Envelope.java -9df1e0a689390bda0847a0acab5a13fd7ced8c09b8563c13a7ce000ef1ea4999 gencode/java/udmi/schema/Events.java +09916ecaab0ae9ec10f39acdfbfe1179e74051ecc25143f695247b3a1a65de60 gencode/java/udmi/schema/Envelope.java +c6ce13a723c6b966912aab9b6deabf7e2ae6e1d74812cc3d7f15726a2fbd76c9 gencode/java/udmi/schema/Events.java 8aed152c906c51a72a9224e7aec14bcdbd9ac2e00c2b1fed6bd5515082f79488 gencode/java/udmi/schema/ExecutionConfiguration.java a5a41a2bc809988efbbc121cdf730cbc1eb066372e046747fb6ae3f30e96a8b1 gencode/java/udmi/schema/FamilyDiscovery.java 0de804fb208fdf2ef198539ed5b828b780bc6c5cdd9e31b10067372165c4b7d4 gencode/java/udmi/schema/FamilyDiscoveryConfig.java @@ -90,7 +93,7 @@ cad5d302b99c488505009d32709096a24b3781c51c9f2d4312b018cd11a2e2dd gencode/java/u aa7ec0811b050bc00088b3b96f0048e779b9796ef976063ed807f4aafae4467c gencode/java/udmi/schema/GatewayModel.java acb74e9f15299bf6328c9b61f3171a264042acf1227f0179221499e98a24ae59 gencode/java/udmi/schema/GatewayState.java 9fcc5158805ecbfa43c258d8b4e5ba8940d533b0988a033833814be1f5646d67 gencode/java/udmi/schema/IotAccess.java -bde555af10fb11bf0423d2a77c98661d00e82f804fe8de74cb8954003811609e gencode/java/udmi/schema/Jwt.java +096e2adc70e6bac0a4906f8c20cea3be3c34eb8e91072952920b204b6a0321cc gencode/java/udmi/schema/Jwt.java 5a0cc53317592a868f7f23a67a7493bd571054bb25d9e28de51000ad6473dfeb gencode/java/udmi/schema/Level.java 52cee4085ee0571121061ec343f929ccc964256081a70d9579f428220209e205 gencode/java/udmi/schema/LinkExternalsModel.java 2c40b22754f0409c38394bcd1a672c5501fee147d6f0824336b41f852539ff62 gencode/java/udmi/schema/LinkExternalsSite.java @@ -104,6 +107,7 @@ e4e967da341ca824c7688b31c440b27d2247ab8e74abfefd584c28bfc99441f2 gencode/java/u 189c32fc3b4b4d5a6f011b3ef8c7119278a19fbe490b5327c029ca471cfebed6 gencode/java/udmi/schema/Monitoring.java a43b73cbf5baadc6cd054f26aa97be58d3738a046acbba7c2ccedc2a21a1d7ac gencode/java/udmi/schema/MonitoringMetric.java a1228e33420a983b1e7a208d04089fd4595a082a1f91f7f86585c31c9d9c4164 gencode/java/udmi/schema/MosquittoClientResponse.java +667d030bc13f4d82821e96e17e0d02c1d89c85bec89b3ca5d94294f097724c2f gencode/java/udmi/schema/Mtls.java 7e85b3026d88a0d26c8db2f136dd9282ab782ecc631e6a081a5008cfff20f41a gencode/java/udmi/schema/Operation.java 00c2db8f273af8ed63f8ab8dd5cc2e9c6d2139a81fe89e5fa87a275a5cb28dbb gencode/java/udmi/schema/Parameters.java 7671355028d5121231f9c978d0a33faf0c39b651526f4164dba8742c5feb5db4 gencode/java/udmi/schema/Parent.java @@ -137,7 +141,6 @@ b8b388f59da9ee6951ba85b2c12e171681e6abe7c97312cf758344e0850c640c gencode/java/u 81df6abb662cbf5dfc4301d770c5e0bf72d07f228dc725b8df35339e73b90762 gencode/java/udmi/schema/StateSystemHardware.java 0110ab92d49749785dc29b92685443c895efa3cbc0637ed4374d3337d2f3be54 gencode/java/udmi/schema/StateSystemOperation.java c7e5fd53e7e1c3a439b5283f81b0555473d6b0e623aa9cdd896080b993a6f80f gencode/java/udmi/schema/States.java -5d531ecfda463512a1f6a97d0b9aa8c0e0f163f5dd63bfcfa63c406d4f820eae gencode/java/udmi/schema/StreamsEvents.java b45a8fd3c70f75bed5f3aa2285007baa6445a23819b31fe69f38c117f837bbab gencode/java/udmi/schema/Structure.java 65babc897b81d05b69e6178433b0918e513e36e861f044b68ecf4eb5f2408b37 gencode/java/udmi/schema/SystemConfig.java 53667a5822c79a1aca9345399e9dd925b761fefb96b06ff2f7c42705256a4112 gencode/java/udmi/schema/SystemDiscoveryData.java @@ -158,9 +161,9 @@ f08d327f322431a8be0e6018a7b8a9835af8e3e6ecc4ab8e18eb36810a44e5f3 gencode/java/u 225a2e3fa5a50c197ef50b55a81dc6efd33cd09a678c2c55112858f0086fd877 gencode/java/udmi/schema/ValidationSummary.java 5ec93668911c5b801e764b07ce538ab98cb8b2d211a530bce3c902c4a7773e40 gencode/java/udmi/schema/Values.java c99d2ec53e670c3b91104a4a8fbc825c1c29530a2f686fe644f977eb6b6edd89 gencode/java/udmi/schema/Water.java -f4fede5fe11244aa2391dcc6dc94c126a453bc51e292555bbc5994e52beaf27c gencode/presentation/presentation.json +ded07912869cb93817cbf891922d2f67d983fbd10a0a2a736f8d10e05673795d gencode/presentation/presentation.json 4cf98cbd132cde0cc8813ac35cf3712cb46014154c817c04ad2902c268cdd8fe gencode/python/pyproject.toml -f6968fad6fb525139cd063b5a9dc00a55faab999d2a429f0843ca40f54f7727b gencode/python/udmi/schema/__init__.py +cee4b5935d1b60064b5d95c64df5ff9531e0c4b19815ef39ab185836fff50417 gencode/python/udmi/schema/__init__.py f9d90861e568b27445bef241f04cce64cc44731c95c8bd9e3f65cef79d42dab0 gencode/python/udmi/schema/_base.py de7d13c346e5d98511d02b4bdccf20afeb013eaca512694ebd7f82e584b11358 gencode/python/udmi/schema/access_iot.py 32a951e2bf13f556082f8d94be079b3df6cc081b6ff59f71a82d32782cf8f8f6 gencode/python/udmi/schema/ancillary_properties.py @@ -169,7 +172,7 @@ de7d13c346e5d98511d02b4bdccf20afeb013eaca512694ebd7f82e584b11358 gencode/python 485009c8c773dab9f54c39efcf8e761d00059594a621435b11fc095fdcc71b29 gencode/python/udmi/schema/building_translation.py f96756baafd452dfdf34db09e93f4ca700895866a20aef6afb3395bb02369e5f gencode/python/udmi/schema/category.py e92d040fe84cc1bbc6e6b2d81a39dae156e1501e1c07a0b6cfc42e1599403e64 gencode/python/udmi/schema/commands_discovery.py -5ab5446f694b30f1040067ce99c455c5a9cb8807fa2c2aaccbc171738d4893db gencode/python/udmi/schema/common.py +339f431e46facabebe048d622c9a7c5fba0b195daa3515410f7ab5fb95da3c2e gencode/python/udmi/schema/common.py 59639574385a4eb18b85f8b545b69b066a45f868c4645bdaf5a87ad572772b78 gencode/python/udmi/schema/config.py 9f70f080bff148c1d31707e7f6f023ed558f738fa4db8ad3f4b71868d43fbf6a gencode/python/udmi/schema/config_alarmset.py 77c940663e59bd14bcfad0b6b83aaded3339ce032a2ef5764717c3d78b485880 gencode/python/udmi/schema/config_alarmset_alarm.py @@ -186,7 +189,7 @@ b65e7e9152629a9175f62ed706701d02814d62d8f3e9c0b46c2ba49ad8b51077 gencode/python faf4cdb1687868fadc411c144d57a5c596af790e43df4d7ddec5ee3fd10ba4bd gencode/python/udmi/schema/config_system.py ddd6e974840a19aa75e2f322e140676f71e0715f69088b88dfea76f0625ab035 gencode/python/udmi/schema/config_system_testing.py f187d5acbf931e97365af4283ee676786b7f26294db720b1c8535ab50387a6c1 gencode/python/udmi/schema/config_udmi.py -d1cf489cbf937f801213f1425508afcc37c548e08f1da6ac755f9282bd84992d gencode/python/udmi/schema/configuration_endpoint.py +4fba5bbff9a5d1c593985e6dc610484e34213a63b74c5fd128cc85ae1e25af9d gencode/python/udmi/schema/configuration_endpoint.py 51699ab01b8d7be4db89c8e3923e0abd41a5a05e692e252deb59f10eb8189cfe gencode/python/udmi/schema/configuration_execution.py c0853ff1838a11291b53a40c43a6cdaff17951a34b3d7f3de10d9fcc7b44b79d gencode/python/udmi/schema/configuration_pod.py 62f2a07047c4cf635f2f5d021a0da433e3d38acfe9a02fcd96581aec5c1896af gencode/python/udmi/schema/configuration_pod_base.py @@ -197,16 +200,16 @@ f7193dd8eddcae6c646b99fb4c33fe47f43bed7c9615ab1bd0a0bf5612527607 gencode/python 606420089c2016cf49edd1d3b570928b404b5ac05193fd3149acaaa3fd247eb0 gencode/python/udmi/schema/discovery_family.py 86f262123525f2e6119c174fe4a4cd2f2c35863ced03a66f6db32c08e5a08542 gencode/python/udmi/schema/discovery_feature.py b978caf892bc6d0fb875b2560b81668cd1a47a09436609dc56d6353ef35b5f86 gencode/python/udmi/schema/discovery_ref.py +397067016168cf76300077d8219a74863ad0f70e455f4fe2796b94d6bf50c2af gencode/python/udmi/schema/download_blobset.py be97796ac8949ccb7fbdecfb4ff087987cf2849bf223ff39b947771a08422156 gencode/python/udmi/schema/entry.py -ab1a8ee1f12d04dd88837b6f026030b5ed3550ef3bfbbfbcb4f77cc4df39e93a gencode/python/udmi/schema/envelope.py +118a2a5de36a328dc057a784fd631a635d9710f79d208853ea02ff7c9cb245b4 gencode/python/udmi/schema/envelope.py 04f518ca330acb6f376838bdc4425f15582eb185f4b05f00fc76d1af92bac57f gencode/python/udmi/schema/equipment_translation.py -3dcf4daa94b8450b7360e58500e20e1fbe5701502ac28358112ee1a6bd2a87f1 gencode/python/udmi/schema/events.py +555d45b8882e31095e7233bfffbade89840535aff33dbe20cd24e6808b6205a3 gencode/python/udmi/schema/events.py af48a2d32b4ec326df6e3f165d8944b994fb426bd4e663c42c1d592f5cb41f83 gencode/python/udmi/schema/events_alarmset.py b58371ffa7979d1598502a35859f3010ae7a2ec2d851454c010c835e426c7a14 gencode/python/udmi/schema/events_alarmset_alarm.py 3996175098735e889730c87ffc9ce1648e6624d801694458f6d4bc147ff29741 gencode/python/udmi/schema/events_discovery.py 84700f9d8a8975130ff9c62891003f7a12f367fa7dd1ca1db5f5fae7225bf005 gencode/python/udmi/schema/events_pointset.py 9f568bd573d4992e3efa3cc3cb8cdefecb95b15675b0442873abdc7850d108f9 gencode/python/udmi/schema/events_pointset_point.py -282439e4983ae9f310fda69dd6ff979567fa983ebdefcde407df42d631fe1bad gencode/python/udmi/schema/events_streams.py e4cdab07942e52cae243b4dddbc72676794914947dd66df8add0f5969f31fd11 gencode/python/udmi/schema/events_system.py 8c5a6101e62c751714a7440db3993167b7d86bffd80589076bca1feb0a91d214 gencode/python/udmi/schema/events_udmi.py 72559779680c79dace3c5903022709852cbad7b77ed42af97debc8d852d61cb7 gencode/python/udmi/schema/events_validation.py @@ -264,3 +267,4 @@ e23b1b67fc5066ee0c9a948fbc0b31bc425d73740a8d2104b30004173f049f4a gencode/python 6c62d12f392525e4c08d3ee6105f475b941c21e0a8b7a23eaf1650d01a5b5aae gencode/python/udmi/schema/state_validation_feature.py 8a92d352337c118831e3a3b64b350a5ab248fc29150e7cdb968d5e5cf0aae30c gencode/python/udmi/schema/state_validation_schema.py 8862c955ea0aee91db532cb076112305ce6e18d9ac5b323f758fd7fcf75db7cd gencode/python/udmi/schema/state_validation_sequence.py +bc37f27cf5c91e564e55e2d31adda5753b13339f5b3368327153258131d5cee4 gencode/python/udmi/schema/upload_discovery.py diff --git a/bin/start_mosquitto b/bin/start_mosquitto index 28195a3005..0da444a264 100755 --- a/bin/start_mosquitto +++ b/bin/start_mosquitto @@ -184,6 +184,10 @@ $MOSQUITTO_CTRL addRoleACL service subscribePattern '/uufi/#' allow || true $MOSQUITTO_CTRL addRoleACL service publishClientSend '/uufi/#' allow || true $MOSQUITTO_CTRL addRoleACL service subscribePattern 'events/#' allow || true $MOSQUITTO_CTRL addRoleACL service publishClientSend 'events/#' allow || true +$MOSQUITTO_CTRL addRoleACL service subscribePattern 'upload/#' allow || true +$MOSQUITTO_CTRL addRoleACL service publishClientSend 'upload/#' allow || true +$MOSQUITTO_CTRL addRoleACL service subscribePattern 'download/#' allow || true +$MOSQUITTO_CTRL addRoleACL service publishClientSend 'download/#' allow || true $MOSQUITTO_CTRL addRoleACL service subscribePattern 'state/#' allow || true $MOSQUITTO_CTRL addRoleACL service publishClientSend 'state/#' allow || true $MOSQUITTO_CTRL addRoleACL service subscribePattern 'state' allow || true diff --git a/bin/test_mosquitto b/bin/test_mosquitto index 14c94a633d..f8b07d4937 100755 --- a/bin/test_mosquitto +++ b/bin/test_mosquitto @@ -70,7 +70,9 @@ $MOSQUITTO_CTRL addClientRole $dev_id $role_name $MOSQUITTO_CTRL addRoleACL $role_name subscribePattern "$dev_id/config" allow $MOSQUITTO_CTRL addRoleACL $role_name subscribePattern "$dev_id/commands" allow $MOSQUITTO_CTRL addRoleACL $role_name subscribePattern "$dev_id/errors" allow +$MOSQUITTO_CTRL addRoleACL $role_name subscribePattern "$dev_id/download/#" allow $MOSQUITTO_CTRL addRoleACL $role_name publishClientSend "$dev_id/events/#" allow +$MOSQUITTO_CTRL addRoleACL $role_name publishClientSend "$dev_id/upload/#" allow $MOSQUITTO_CTRL addRoleACL $role_name publishClientSend "$dev_id/state" allow sleep 1 diff --git a/clientlib/python/src/udmi/core/device.py b/clientlib/python/src/udmi/core/device.py index 6b4c5f87f5..d4ba37add2 100644 --- a/clientlib/python/src/udmi/core/device.py +++ b/clientlib/python/src/udmi/core/device.py @@ -30,11 +30,16 @@ from udmi.core.persistence import DevicePersistence from udmi.core.persistence.file_backend import FilePersistenceBackend from udmi.core.utils.file_ops import mask_secrets +from udmi.schema import BlobBlobsetConfig +from udmi.schema import BlobBlobsetState +from udmi.schema import BlobsetDownload +from udmi.schema import BlobsetState from udmi.schema import Config from udmi.schema import EndpointConfiguration from udmi.schema import Metadata from udmi.schema import State from udmi.schema import SystemState +from udmi.schema.common import Phase LOGGER = logging.getLogger(__name__) @@ -139,6 +144,8 @@ def __init__(self, self._state_lock = threading.RLock() self._redirection_handler: Optional[RedirectionHandler] = None self._connection_handler: Optional[ConnectionHandler] = None + self._pending_endpoint_blob: Optional[BlobBlobsetConfig] = None + self._endpoint_blob_state: Optional[BlobBlobsetState] = None LOGGER.info("Device initialized with %s managers.", len(self.managers)) @property @@ -193,7 +200,8 @@ def _setup_message_handlers(self) -> None: return self.dispatcher.register_handler("config", self.handle_config) self.dispatcher.register_handler("commands/#", self.handle_command) - LOGGER.debug("Registered config and command handlers.") + self.dispatcher.register_handler("download/#", self.handle_download) + LOGGER.debug("Registered config, command, and download handlers.") # --- Connection Callbacks --- @@ -266,8 +274,28 @@ def handle_config(self, device_id: str, _channel: str, payload: Dict) -> None: return if self._has_new_endpoint_config(config_obj): - self._try_redirect_endpoint(config_obj) - return + blob_config = config_obj.blobset.blobs.get( + IOT_ENDPOINT_CONFIG_BLOB_KEY + ) + if blob_config and blob_config.url and blob_config.url.startswith("download://"): + LOGGER.info( + "Endpoint blob uses download:// scheme. Requesting download..." + ) + self._pending_endpoint_blob = blob_config + self._endpoint_blob_state = BlobBlobsetState( + phase=Phase.apply, + generation=blob_config.generation, + ) + else: + self._try_redirect_endpoint(config_obj) + return + elif not ( + config_obj.blobset + and config_obj.blobset.blobs + and IOT_ENDPOINT_CONFIG_BLOB_KEY in config_obj.blobset.blobs + ): + self._pending_endpoint_blob = None + self._endpoint_blob_state = None self._loop_state.config_received_event.set() self.config = config_obj @@ -305,6 +333,64 @@ def handle_command(self, device_id: str, channel: str, payload: Dict[str, Any]) LOGGER.error("Error in %s.handle_command: %s", manager.__class__.__name__, e) + def handle_download( + self, device_id: str, channel: str, payload: Dict[str, Any] + ) -> None: + """ + Orchestration method to handle a new download payload. + + Args: + device_id: The ID of the device this download is for. + channel: The full download channel (e.g., 'download/blobset'). + payload: The pre-parsed dictionary of the JSON payload. + """ + download_name = channel.split('/')[-1] + + if device_id != self.device_id: + self._route_proxy_download(device_id, download_name, payload) + return + + LOGGER.info( + "Download '%s' received for Device %s...", + download_name, + self.device_id, + ) + if download_name == "blobset": + try: + blobset_download = BlobsetDownload.from_dict(payload) + if ( + blobset_download.blobs + and IOT_ENDPOINT_CONFIG_BLOB_KEY in blobset_download.blobs + ): + new_endpoint = blobset_download.blobs[ + IOT_ENDPOINT_CONFIG_BLOB_KEY + ] + generation = ( + self._pending_endpoint_blob.generation + if self._pending_endpoint_blob + else None + ) or "download" + self._endpoint_blob_state = BlobBlobsetState( + phase=Phase.final, + generation=generation, + ) + self._pending_endpoint_blob = None + self._apply_redirect_endpoint(new_endpoint, generation) + self._publish_state(bypass_throttle=True, blocking=True) + except (TypeError, ValueError) as e: + LOGGER.error("Failed to parse download/blobset message: %s", e) + + for manager in self.managers: + if hasattr(manager, "handle_download"): + try: + manager.handle_download(download_name, payload) + except (AttributeError, TypeError, KeyError, ValueError) as e: + LOGGER.error( + "Error in %s.handle_download: %s", + manager.__class__.__name__, + e, + ) + # --- Proxy Routing Helpers --- def _route_proxy_config(self, device_id: str, payload: Dict) -> None: @@ -342,6 +428,33 @@ def _route_proxy_command(self, device_id: str, command_name: str, payload: Dict) if not handled: LOGGER.debug("Received command for proxy '%s' but no GatewayManager found.", device_id) + def _route_proxy_download( + self, device_id: str, download_name: str, payload: Dict + ) -> None: + """ + Routes a download message meant for a proxy device to the GatewayManager. + """ + handled = False + for manager in self.managers: + if hasattr(manager, "handle_proxy_download"): + try: + manager.handle_proxy_download( + device_id, download_name, payload + ) + handled = True + except Exception as e: # pylint: disable=broad-exception-caught + LOGGER.error( + "Error routing proxy download to %s: %s", + manager.__class__.__name__, + e, + ) + + if not handled: + LOGGER.debug( + "Received download for proxy '%s' but no GatewayManager found.", + device_id, + ) + # --- Device lifecycle and endpoint management --- def _has_new_endpoint_config(self, config: Config) -> bool: @@ -372,25 +485,31 @@ def _try_redirect_endpoint(self, config: Config) -> None: EndpointConfiguration ) - LOGGER.info("Endpoint blob fetched. Generation: %s. Saving...", - generation) + self._apply_redirect_endpoint(new_endpoint, generation) - self.persistence.save_active_endpoint(new_endpoint, generation) - self.current_endpoint = new_endpoint - self.device_id = self.current_endpoint.client_id.split('/')[-1] + except Exception as e: # pylint:disable=broad-exception-caught + LOGGER.error("Failed to process endpoint redirect: %s", e) - if self._redirection_handler: - try: - LOGGER.info("Invoking redirection handler...") - self._redirection_handler(new_endpoint) - except Exception as e: # pylint: disable=broad-exception-caught - LOGGER.error("Error in redirection handler: %s", e) + def _apply_redirect_endpoint( + self, new_endpoint: EndpointConfiguration, generation: str + ) -> None: + """Saves and activates a new endpoint configuration.""" + LOGGER.info("Endpoint blob fetched. Generation: %s. Saving...", + generation) - LOGGER.info("Signaling main loop to trigger connection reset...") - self._loop_state.reset_event.set() + self.persistence.save_active_endpoint(new_endpoint, generation) + self.current_endpoint = new_endpoint + self.device_id = self.current_endpoint.client_id.split('/')[-1] - except Exception as e: # pylint:disable=broad-exception-caught - LOGGER.error("Failed to process endpoint redirect: %s", e) + if self._redirection_handler: + try: + LOGGER.info("Invoking redirection handler...") + self._redirection_handler(new_endpoint) + except Exception as e: # pylint: disable=broad-exception-caught + LOGGER.error("Error in redirection handler: %s", e) + + LOGGER.info("Signaling main loop to trigger connection reset...") + self._loop_state.reset_event.set() # --- Main Run Loop --- @@ -532,6 +651,14 @@ def _publish_state( except (AttributeError, TypeError, KeyError, ValueError) as e: LOGGER.error("Error in %s.update_state: %s", manager.__class__.__name__, e) + if self._endpoint_blob_state is not None: + if self.state.blobset is None: + self.state.blobset = BlobsetState(blobs={}) + elif self.state.blobset.blobs is None: + self.state.blobset.blobs = {} + self.state.blobset.blobs[IOT_ENDPOINT_CONFIG_BLOB_KEY] = ( + self._endpoint_blob_state + ) should_block = bypass_throttle if blocking is None else blocking self.dispatcher.publish_state(self.state, wait=should_block) self._loop_state.last_state_publish_time = time.time() diff --git a/clientlib/python/src/udmi/core/managers/base_manager.py b/clientlib/python/src/udmi/core/managers/base_manager.py index 7ece646c0e..46d1982008 100644 --- a/clientlib/python/src/udmi/core/managers/base_manager.py +++ b/clientlib/python/src/udmi/core/managers/base_manager.py @@ -102,6 +102,24 @@ def publish_event(self, event_model: DataModel, subfolder: str, self._dispatcher.publish_event(f"events/{subfolder}", event_model, device_id) + def publish_upload(self, upload_model: DataModel, subfolder: str, + device_id: Optional[str] = None) -> None: + """ + Helper method for managers to publish upload stream messages. + + Args: + upload_model: The UDMI data model to publish. + subfolder: The upload subfolder (e.g., 'discovery'). + device_id: (Optional) The device ID to publish for. + """ + if not self._dispatcher: + LOGGER.error("Manager %s cannot publish upload: dispatcher not set.", + self.__class__.__name__) + return + + self._dispatcher.publish_event(f"upload/{subfolder}", upload_model, + device_id) + def trigger_state_update(self, immediate: bool = False) -> None: """ Requests the device to publish its state. @@ -196,6 +214,12 @@ def handle_command(self, command_name: str, payload: dict) -> None: """ raise NotImplementedError + def handle_download(self, download_name: str, payload: dict) -> None: + """ + Handle a newly received download message. + Optional to implement. + """ + @abc.abstractmethod def update_state(self, state: State) -> None: """ diff --git a/clientlib/python/src/udmi/core/messaging/mqtt_messaging_client.py b/clientlib/python/src/udmi/core/messaging/mqtt_messaging_client.py index 2995cd3918..a51854ed5d 100644 --- a/clientlib/python/src/udmi/core/messaging/mqtt_messaging_client.py +++ b/clientlib/python/src/udmi/core/messaging/mqtt_messaging_client.py @@ -196,8 +196,9 @@ def register_channel_subscription(self, channel: str, topic = "/" + topic LOGGER.info("Dynamic subscription to: %s", topic) + qos = 0 if channel.startswith("download") else 1 try: - self._mqtt_client.subscribe(topic, qos=1) + self._mqtt_client.subscribe(topic, qos=qos) except Exception as e: # pylint: disable=broad-exception-caught LOGGER.error("Subscribe failed for %s: %s", topic, e) @@ -260,8 +261,9 @@ def _on_connect(self, client: Any, _userdata: Any, _flags: Any, topic = f"{self._topic_prefix_str}/{device_id}/{channel}" if not topic.startswith("/"): topic = "/" + topic + qos = 0 if channel.startswith("download") else 1 try: - client.subscribe(topic, qos=1) + client.subscribe(topic, qos=qos) except Exception as e: # pylint: disable=broad-exception-caught LOGGER.error("Subscribe failed for %s: %s", topic, e) diff --git a/clientlib/python/tests/core/messaging/test_mqtt_messaging_client.py b/clientlib/python/tests/core/messaging/test_mqtt_messaging_client.py index 2919e98537..913484637e 100644 --- a/clientlib/python/tests/core/messaging/test_mqtt_messaging_client.py +++ b/clientlib/python/tests/core/messaging/test_mqtt_messaging_client.py @@ -192,16 +192,18 @@ def test_on_connect_subscribes_to_channels(mqtt_client, """ test_on_connect_subscribes_to_channels Verify mock_paho_client_instance.subscribe was called with - the full topic. + the full topic and appropriate QoS (0 for download, 1 otherwise). """ mqtt_client.register_channel_subscription("config") mqtt_client.register_channel_subscription("commands/#") + mqtt_client.register_channel_subscription("download/#") mqtt_client._on_connect(mock_paho_client_instance, None, None, 0) expected_calls = [ call("/devices/d/config", qos=1), - call("/devices/d/commands/#", qos=1) + call("/devices/d/commands/#", qos=1), + call("/devices/d/download/#", qos=0), ] mock_paho_client_instance.subscribe.assert_has_calls( expected_calls, diff --git a/clientlib/python/tests/core/test_device.py b/clientlib/python/tests/core/test_device.py index b4e9c81f8b..ad28d33e99 100644 --- a/clientlib/python/tests/core/test_device.py +++ b/clientlib/python/tests/core/test_device.py @@ -233,6 +233,65 @@ def test_redirection_trigger(mock_parse_blob, test_device): assert test_device._loop_state.reset_event.is_set() +def test_download_channel_redirection_flow(test_device, mock_dispatcher): + """ + Verifies that a download:// endpoint blob URL transitions state to 'apply' + and then applies the EndpointConfiguration delivered on 'download/blobset'. + """ + mock_persistence = MagicMock() + test_device.persistence = mock_persistence + mock_persistence.get_active_generation.return_value = "old-gen" + + blob_config = BlobBlobsetConfig( + generation="new-gen", + url="download://blobset/_iot_endpoint_config", + sha256="deadbeef", + ) + config = Config( + blobset=BlobsetConfig( + blobs={IOT_ENDPOINT_CONFIG_BLOB_KEY: blob_config} + ) + ) + + with patch("src.udmi.core.device.STATE_THROTTLE_SEC", 0): + test_device.handle_config( + test_device.device_id, "config", config.to_dict() + ) + + assert not test_device._loop_state.reset_event.is_set() + assert ( + test_device.state.blobset.blobs[IOT_ENDPOINT_CONFIG_BLOB_KEY].phase.value + == "apply" + ) + mock_dispatcher.publish_state.assert_called_once() + + download_payload = { + "timestamp": "2026-03-31T10:00:00Z", + "version": "1.5.7", + "blobs": { + IOT_ENDPOINT_CONFIG_BLOB_KEY: { + "protocol": "mqtt", + "hostname": "new.broker.local", + "port": 8883, + "client_id": "projects/p/locations/l/registries/r/devices/d", + } + }, + } + test_device.handle_download( + test_device.device_id, "download/blobset", download_payload + ) + + mock_persistence.save_active_endpoint.assert_called_once() + saved_endpoint, saved_gen = mock_persistence.save_active_endpoint.call_args[0] + assert saved_endpoint.hostname == "new.broker.local" + assert saved_gen == "new-gen" + assert ( + test_device.state.blobset.blobs[IOT_ENDPOINT_CONFIG_BLOB_KEY].phase.value + == "final" + ) + assert test_device._loop_state.reset_event.is_set() + + # --- Resilience / Fallback Tests --- def test_connection_fallback_clears_active_endpoint(test_device): diff --git a/docs/messages/readme.md b/docs/messages/readme.md index f1d2bf0411..67826ae896 100644 --- a/docs/messages/readme.md +++ b/docs/messages/readme.md @@ -34,14 +34,17 @@ flow, but also the treatment of the message as it moves through the system: |---------|---------|-----------| | __Sticky__ | _state_ | _config_ | | __Stream__ | _events_ | _command_ | +| __Out-of-Band / Ephemeral__ | _upload_ | _download_ | -The properties and uses of the four types fall out from this accordingly: +The properties and uses of the message types fall out from this accordingly: * _state_: Sticky to the cloud information from the device, including information such as the overall status of the device, and any errors or conditions from, e.g., writing points. * _events_: The canonical streaming telemetry messages from the device, usually containing things like temperature readings or system memory utilization. * _config_: Ability to control the behavior of a device, e.g. for key rotation, writeback, etc... * _command_: Direct but transitory messages to a device, e.g. to install a new auth key or perform diagnostic operations. +* _upload_: Chunked bulk binary or text artifact transfers from the device to the cloud (e.g., `upload/discovery` for packet captures), framed by `config` and `state`. +* _download_: Ephemeral, non-retained (`QoS 0`, `retain = false`) payload delivery from the cloud to the device (e.g., `download/blobset` for credentials and private keys), framed by `config.blobset` and `state.blobset`. Individual blocks (folders), such as _system_ or _pointset_ will have their own semantic uses for the various types. See the individual block folder documentation to learn more about those aspects. @@ -55,6 +58,10 @@ See the individual block folder documentation to learn more about those aspects. - [Alarmset (telemetry)](alarmset.md#telemetry) ([_🧬schema_](../../gencode/docs/events_alarmset.html)) - [System (logging, etc)](system.md) ([_🧬schema_](../../gencode/docs/events_system.html)) - [Discovery](../specs/discovery.md) ([_🧬schema_](../../gencode/docs/events_discovery.html)) +- upload + - [Discovery Upload](../specs/discovery.md#discovery-uploads) ([_🧬schema_](../../gencode/docs/upload_discovery.html)) +- download + - [Blobset Download](../specs/blob_updates.md#ephemeral-blobset-downloads) ([_🧬schema_](../../gencode/docs/download_blobset.html)) - [envelope](envelope.md) ## MQTT Topics diff --git a/docs/specs/blob_updates.md b/docs/specs/blob_updates.md index 2ac15d8c5d..c54b8c5fb3 100644 --- a/docs/specs/blob_updates.md +++ b/docs/specs/blob_updates.md @@ -137,3 +137,43 @@ Granular observability relies on consistent telemetry. When tracking the lifecyc } } ``` + +--- + +## Ephemeral Blobset Downloads (`download/blobset`) + +When delivering sensitive credentials, private keys, or ephemeral tokens (`_iot_endpoint_config`, `_bacnet_sc_config`, `_discovery_auth`), embedding secret material inline in `config.blobset` (via `data:` URLs) is undesirable because `config` messages are retained by the MQTT broker and stored in operational databases. + +Instead, the cloud can specify a `download://` URL (e.g., `download://_iot_endpoint_config`) in `config.blobset.blobs..url` and deliver the sensitive payload out-of-band over the `{topic_prefix}/download/blobset` MQTT topic ([`schema/download_blobset.json`](../../schema/download_blobset.json)). + +### Transport & Security Guarantees +* **QoS 0 & Non-Retained**: Both the publisher and device subscriber **must** use **MQTT QoS 0** with `retain = false` on `{topic_prefix}/download/blobset` so the broker never persists unacknowledged secret payloads to durable session disk. +* **Direct Broker Delivery**: UDMIS pushes `download/blobset` messages directly to the MQTT broker without routing the secret payload through intermediate Pub/Sub topics. + +### System Blobset Keys (`schema/common.json#/definitions/blobsets`) +Standardized UDMI system blob keys use a leading underscore: +* `_iot_endpoint_config`: Primary MQTT broker endpoint configuration and credentials (including automated key/password rotation). +* `_bacnet_sc_config`: BACnet Secure Connect (BACnet/SC) operational certificates, private keys, and CA bundle. +* `_discovery_auth`: Ephemeral local discovery authentication credentials (e.g., vendor controller API username/password or token). + +### `EndpointConfiguration` Extensions (`schema/configuration_endpoint.json`) +Each entry in `download/blobset` (`blobs.`) conforms to [`EndpointConfiguration`](../../schema/configuration_endpoint.json), which supports: +* **`expiry`** (`date-time`): Optional RFC 3339 UTC timestamp indicating when an ephemeral credential expires. Once `now >= expiry`, the device must purge the credential from memory and, if the blob is still active in `config.blobset`, transition `state.blobset.blobs..phase` back to `apply` to request a refreshed credential. +* **`auth_provider.basic`**: `username` and `password`. +* **`auth_provider.jwt`**: `audience` and optional pre-signed `token`. +* **`auth_provider.mtls`**: + * `private_key`: Client private key PEM for mTLS authentication. + * `certificate`: Client X.509 certificate PEM for mTLS authentication. + * `ca_certificate`: Trusted CA certificate bundle PEM for verifying the peer or hub. + +### Handshake & Crash Recovery Flow +1. **Config Notification**: Cloud publishes `config.blobset.blobs.` with `phase: "final"`, `generation`, `sha256` of the serialized `EndpointConfiguration`, and `url: "download://"`. +2. **Download Request (`phase: "apply"`)**: Seeing a new `generation` with a `download://` URL (or recovering after a reboot/crash where the ephemeral secret is no longer in memory), the device publishes `state.blobset.blobs..phase = "apply"` with the matching `generation`. +3. **Ephemeral Delivery**: Upon receiving `state.blobset.blobs..phase == "apply"`, UDMIS publishes a [`BlobsetDownload`](../../schema/download_blobset.json) message to `{topic_prefix}/download/blobset` containing `blobs.`. +4. **Application & Acknowledgement (`phase: "final"`)**: The device applies the `EndpointConfiguration` and publishes `state.blobset.blobs..phase = "final"`. + +### Coexistence with Inline `data:` URLs +To maintain backward compatibility with existing devices and test sequences: +* **`data:` URLs**: Non-secret endpoint redirections (such as updating `hostname`, `port`, or `client_id` while reusing an existing on-device private key) continue to use `data:application/json;base64,...` inline in `config.blobset.blobs._iot_endpoint_config.url`. Clients decode and apply `data:` URLs immediately upon receiving `config`. +* **`download://` URLs**: Secret-bearing blobs (`basic.password`, `jwt.token`, `mtls.private_key`, `_bacnet_sc_config`, `_discovery_auth`) use `download://...`, triggering the `state` (`phase: "apply"`) → `download/blobset` → `state` (`phase: "final"`) flow. + diff --git a/docs/specs/discovery.md b/docs/specs/discovery.md index 51709c4cce..133384b354 100644 --- a/docs/specs/discovery.md +++ b/docs/specs/discovery.md @@ -63,3 +63,16 @@ be reported: * `blobs`: A listing of all the data blobs that a device knows how to handle. This could be components like firmware updates, key rotation, etc... Some blobs will be standardized across the system, while others will be device-specific. + +## Discovery Uploads (`upload/discovery`) + +When a discovery scan produces bulk binary or text capture artifacts—such as network packet captures (`pcap` / `pcapng`) triggered via `config.discovery.families.`—the device streams the capture in chunks over `{topic_prefix}/upload/discovery` using the [`DiscoveryUpload`](../../schema/upload_discovery.json) ([_🧬schema_](../../gencode/docs/upload_discovery.html)) format rather than sending individual telemetry events. + +### Chunk Framing & Sequence (`event_no`) +Each `upload/discovery` message is correlated with the active scan via `generation` and sequenced using `event_no`: +* **`event_no = 0` (Start Marker)**: Announces the start of the upload stream (`format`, `generation`). +* **`event_no = 1 .. N` (Data Chunks)**: Sequential 1-based chunks carrying Base64-encoded binary data in `data`. +* **`event_no = -(N + 1)` (Terminal EOF Marker)**: Mandatory final message where the negative value indicates the total chunk count `N` (`N = (-event_no) - 1`) and `sha256` provides the 64-character lowercase hex SHA-256 digest of the complete reassembled artifact. + +In UDMIS, `UploadProcessor` stages incoming chunks in shared storage so out-of-order chunks distributed across multiple stateless pods are reassembled and cryptographically verified against `sha256` once all `1..N` chunks and the terminal `-(N + 1)` marker have arrived. + diff --git a/docs/specs/tech_stack.md b/docs/specs/tech_stack.md index 3ed6adab3c..7e50a29d43 100644 --- a/docs/specs/tech_stack.md +++ b/docs/specs/tech_stack.md @@ -16,12 +16,14 @@ technology stack for compliant IoT devices. # MQTT Topic Suffix Table -| Type | Category | subFolder | MQTT Topic Suffix | Schema File | -| -------- | -------- | --------- | -------------------------------- | ------------- | -| state | state | _n/a_ | `{topic_prefix}/state` | state.json | -| config | config | _n/a_ | `{topic_prefix}/config` | config.json | -| pointset | event | pointset | `{topic_prefix}/events/pointset` | pointset.json | -| system | event | system | `{topic_prefix}/events/system` | system.json | +| Type | Category | subFolder | MQTT Topic Suffix | Schema File | +| --------- | -------- | --------- | --------------------------------- | --------------------- | +| state | state | _n/a_ | `{topic_prefix}/state` | state.json | +| config | config | _n/a_ | `{topic_prefix}/config` | config.json | +| pointset | event | pointset | `{topic_prefix}/events/pointset` | events_pointset.json | +| system | event | system | `{topic_prefix}/events/system` | events_system.json | +| discovery | upload | discovery | `{topic_prefix}/upload/discovery` | upload_discovery.json | +| blobset | download | blobset | `{topic_prefix}/download/blobset` | download_blobset.json | For many implementations the full topic would be `/devices/{device_id}/{suffix}` diff --git a/docs/tools/spotter.md b/docs/tools/spotter.md index 0d8c274160..88c89405a0 100644 --- a/docs/tools/spotter.md +++ b/docs/tools/spotter.md @@ -16,7 +16,7 @@ Spotter runs as a single unified process using the UDMI Python Client Library (` - **`PassiveFamilyProvider`**: Listens for passive broadcast network traffic and extracts discovered device metadata. 2. **`SpotterDiscoveryManager`**: - Manages scheduled and on-demand discovery sweeps. - - Streams live remote packet capture traces (`events/streams`) buffered in volatile memory with circuit-breaker protection (zero-disk streaming). + - Streams live remote packet capture traces (`upload/discovery`) buffered in volatile memory with circuit-breaker protection (zero-disk streaming). 3. **`SpotterSystemManager`**: - Collects host metrics (CPU load, memory, OS distribution) and emits periodic telemetry (`events/system`). - Evaluates memory usage against safety thresholds (`check_safety_circuit_breaker`), throttling active discovery and packet captures to protect edge devices from kernel OOM termination. @@ -48,7 +48,7 @@ graph TD end SYS -->|"state.system / events/system"| MB - DISC -->|"events/discovery & events/streams"| MB + DISC -->|"events/discovery & upload/discovery"| MB LOC -->|"Scans & Probes"| DEV ``` @@ -60,16 +60,16 @@ Spotter processes diagnostic packet capture triggers sent declaratively over the 1. **Capture Worker ([pcap.py](../../edge/spotter/src/pcap.py))**: Spawns `tcpdump` with configurable interface filters, enforcing strict execution bounds (maximum duration and byte quotas). 2. **Streaming MQTT Egress Transport**: - - **Zero-Disk Streaming**: Packets are buffered dynamically in volatile memory (RAM) and sequentially published as reliable base64 chunks (`StreamsEvents`) over the universal streaming MQTT event topic (`events/streams`). + - **Zero-Disk Streaming**: Packets are buffered dynamically in volatile memory (RAM) and sequentially published as reliable base64 chunks (`DiscoveryUpload`) over the streaming MQTT upload topic (`upload/discovery`), terminated by a SHA-256 EOF marker. - **Zero Secret Distribution**: Leverages the existing mTLS hardware key/certificate connection directly, avoiding external network credentials or outbound HTTP rules at the edge. 3. **Ad-hoc PCAP Reassembly ([bin/reassemble_pcap](../../edge/spotter/bin/reassemble_pcap))**: - Reassembles chunked `events/streams` messages (from JSON, JSONL, or `mosquitto_sub`) back into a valid `.pcap` binary capture file: + Reassembles chunked `upload/discovery` messages (from JSON, JSONL, or `mosquitto_sub`) back into a valid `.pcap` binary capture file: ```bash - # Reassemble stream events file into a pcap file: + # Reassemble upload chunks file into a pcap file: ./edge/spotter/bin/reassemble_pcap stream_events.json capture.pcap # Or stream directly from mosquitto subscriber: - mosquitto_sub -h $BROKER -t '/r/+/d/+/events/streams' | ./edge/spotter/bin/reassemble_pcap - live.pcap + mosquitto_sub -h $BROKER -t '/r/+/d/+/upload/discovery' | ./edge/spotter/bin/reassemble_pcap - live.pcap ``` --- diff --git a/edge/spotter/README.md b/edge/spotter/README.md index 78bb4cbd79..9127c0530d 100644 --- a/edge/spotter/README.md +++ b/edge/spotter/README.md @@ -14,7 +14,7 @@ Spotter runs as a single unified process using the UDMI Python Client Library (` - **`PassiveFamilyProvider`**: Listens for passive broadcast network traffic and extracts discovered device metadata. 2. **`SpotterDiscoveryManager`**: - Manages scheduled and on-demand discovery sweeps. - - Streams live remote packet capture traces (`events/streams`) buffered in volatile memory with circuit-breaker protection (zero-disk streaming). + - Streams live remote packet capture traces (`upload/discovery`) buffered in volatile memory with circuit-breaker protection (zero-disk streaming). 3. **`SpotterSystemManager`**: - Collects host metrics (CPU load, memory, OS distribution) and emits periodic telemetry (`events/system`). - Evaluates memory usage against safety thresholds (`check_safety_circuit_breaker`), throttling active discovery and packet captures to protect edge devices from kernel OOM termination. @@ -46,7 +46,7 @@ graph TD end SYS -->|"state.system / events/system"| MB - DISC -->|"events/discovery & events/streams"| MB + DISC -->|"events/discovery & upload/discovery"| MB LOC -->|"Scans & Probes"| DEV ``` @@ -78,16 +78,16 @@ Spotter processes diagnostic packet capture triggers sent declaratively over the 1. **Capture Worker ([pcap.py](src/pcap.py))**: Spawns `tcpdump` with configurable interface filters, enforcing strict execution bounds (maximum duration and byte quotas). 2. **Streaming MQTT Egress Transport**: - - **Zero-Disk Streaming**: Packets are buffered dynamically in volatile memory (RAM) and sequentially published as reliable base64 chunks (`StreamsEvents`) over the universal streaming MQTT event topic (`events/streams`). + - **Zero-Disk Streaming**: Packets are buffered dynamically in volatile memory (RAM) and sequentially published as reliable base64 chunks (`DiscoveryUpload`) over the streaming MQTT upload topic (`upload/discovery`), terminated by a SHA-256 EOF marker. - **Zero Secret Distribution**: Leverages the existing mTLS hardware key/certificate connection directly, avoiding external network credentials or outbound HTTP rules at the edge. 3. **Ad-hoc PCAP Reassembly ([bin/reassemble_pcap](bin/reassemble_pcap))**: - Reassembles chunked `events/streams` messages (from JSON, JSONL, or `mosquitto_sub`) back into a valid `.pcap` binary capture file: + Reassembles chunked `upload/discovery` messages (from JSON, JSONL, or `mosquitto_sub`) back into a valid `.pcap` binary capture file: ```bash - # Reassemble stream events file into a pcap file: + # Reassemble upload chunks file into a pcap file: ./edge/spotter/bin/reassemble_pcap stream_events.json capture.pcap # Or stream directly from mosquitto subscriber: - mosquitto_sub -h $BROKER -t '/r/+/d/+/events/streams' | ./edge/spotter/bin/reassemble_pcap - live.pcap + mosquitto_sub -h $BROKER -t '/r/+/d/+/upload/discovery' | ./edge/spotter/bin/reassemble_pcap - live.pcap ``` --- @@ -98,7 +98,7 @@ Spotter processes diagnostic packet capture triggers sent declaratively over the | :--- | :--- | | **[bin/spotter](../../bin/spotter)** | Unified CLI orchestrator for starting (local/container) and stopping instances | | **[bin/run_spotter_tests](bin/run_spotter_tests)** | Unified verification test runner for unit and integration suites | -| **[bin/reassemble_pcap](bin/reassemble_pcap)** | Ad-hoc CLI utility to reassemble `events/streams` chunks into `.pcap` files | +| **[bin/reassemble_pcap](bin/reassemble_pcap)** | Ad-hoc CLI utility to reassemble `upload/discovery` chunks into `.pcap` files | | **[bin/compare_field_parity](bin/compare_field_parity)** | Automated CLI tool to verify functional parity between legacy node and Spotter | | **[src/agent.py](src/agent.py)** | Main Spotter agent entry point, manager wiring, and command dispatcher | | **[src/providers/](src/providers/)** | Modular protocol discovery providers (BACnet, Ether, Passive) | @@ -189,7 +189,7 @@ sudo ./edge/spotter/bin/compare_field_parity --config /etc/udmi_discovery/config The following capabilities are tracked as future milestones: -1. **UDMIS Native PCAP Ingestion Pipeline**: Native service-side ingestion and reassembly in UDMIS to automatically collect and persist `events/streams` PCAP chunks directly into Cloud Storage (GCS) or BigQuery blob storage. -2. **Ephemeral Secret Delivery**: Architectural mechanism for delivering transient, authenticated operational credentials (e.g., vendor device credentials, BACnet network encryption keys, or REST API bearer tokens) strictly in volatile RAM without disk persistence. A dedicated architectural discussion will finalize the delivery transport (such as non-persisted command channels e.g. `commands/secret` vs encrypted `config.blobset`), memory scrubbing lifecycles, and cryptographic envelope validation before implementation. +1. **UDMIS Native PCAP Ingestion Pipeline**: Native service-side ingestion and reassembly in UDMIS (`UploadProcessor`) to automatically collect and persist `upload/discovery` PCAP chunks directly into Cloud Storage (GCS) or `IotDataProvider`. +2. **Ephemeral Secret Delivery**: Architectural mechanism for delivering transient, authenticated operational credentials (e.g., vendor device credentials, BACnet network encryption keys, or REST API bearer tokens) strictly in volatile RAM without disk persistence via `download/blobset`. 3. **Key Rotation & Lifecycle**: End-to-end device private/public key rotation with automated cloud IoT registry coordination, backup verification, and zero-downtime reconnection. 4. **Expanded Host Observability Metrics**: Network adapter error/drop counters, hardware temperature, storage/inode thresholds, and edge-to-cloud roundtrip latency. diff --git a/edge/spotter/bin/reassemble_pcap b/edge/spotter/bin/reassemble_pcap index a542d9b34a..3dc6636cf9 100755 --- a/edge/spotter/bin/reassemble_pcap +++ b/edge/spotter/bin/reassemble_pcap @@ -1,18 +1,19 @@ #!/usr/bin/env python3 -"""Utility to reassemble UDMI events/streams chunks into a valid PCAP file. +"""Utility to reassemble UDMI upload/discovery chunks into a valid PCAP file. Usage: bin/reassemble_pcap Supported input formats: - - Raw JSON array of StreamsEvents: [ {"event_no": 0, "data": "..."}, ... ] - - JSON Lines (JSONL): One StreamsEvents JSON object per line + - Raw JSON array of DiscoveryUpload: [ {"event_no": 1, "data": "..."}, ... ] + - JSON Lines (JSONL): One DiscoveryUpload JSON object per line - mosquitto_sub output: " " - Standard input (specify "-" as input file) """ import argparse import base64 +import hashlib import json import os import sys @@ -26,7 +27,7 @@ PCAP_MAGIC_NUMBERS = [ def parse_stream_events(raw_text: str): - """Parse StreamsEvents objects from JSON array, JSONL, or mosquitto_sub.""" + """Parse DiscoveryUpload objects from JSON array, JSONL, or mosquitto_sub.""" raw_text = raw_text.strip() if not raw_text: return [] @@ -38,7 +39,7 @@ def parse_stream_events(raw_text: str): if isinstance(data, list): return [item.get("payload", item) if (isinstance(item, dict) and "payload" in item and isinstance(item["payload"], dict)) else item for item in data] if isinstance(data, dict): - if "payload" in data and isinstance(data["payload"], dict) and "data" in data["payload"]: + if "payload" in data and isinstance(data["payload"], dict) and ("data" in data["payload"] or "sha256" in data["payload"]): return [data["payload"]] return [data] except json.JSONDecodeError: @@ -59,8 +60,8 @@ def parse_stream_events(raw_text: str): try: obj = json.loads(payload) if isinstance(obj, dict): - inner = obj.get("payload") if (isinstance(obj.get("payload"), dict) and "data" in obj["payload"]) else obj - if "data" in inner: + inner = obj.get("payload") if (isinstance(obj.get("payload"), dict) and ("data" in obj["payload"] or "sha256" in obj["payload"])) else obj + if "data" in inner or "sha256" in inner: events.append(inner) except json.JSONDecodeError: continue @@ -69,7 +70,7 @@ def parse_stream_events(raw_text: str): def reassemble_pcap(input_source: str, output_path: str) -> None: - """Reassembles chunked events into a binary pcap capture file.""" + """Reassembles chunked upload records into a binary pcap capture file.""" if input_source == "-": raw_content = sys.stdin.read() else: @@ -80,12 +81,14 @@ def reassemble_pcap(input_source: str, output_path: str) -> None: events = parse_stream_events(raw_content) if not events: - sys.exit("Error: No valid StreamsEvents records with 'data' found in input.") + sys.exit("Error: No valid DiscoveryUpload records with 'data' found in input.") - # Filter out records missing data + # Filter out records missing data (such as start/EOF markers) valid_events = [e for e in events if e.get("data")] if not valid_events: - sys.exit("Error: Found stream records but none contained base64 'data'.") + sys.exit("Error: Found upload records but none contained base64 'data'.") + + eof_events = [e for e in events if e.get("sha256")] # Sort primarily by chunk_index, fallback to event_no def sort_key(e): @@ -113,6 +116,14 @@ def reassemble_pcap(input_source: str, output_path: str) -> None: if not pcap_data: sys.exit("Error: Reassembled PCAP payload is empty (0 bytes).") + if eof_events: + expected_sha256 = str(eof_events[-1]["sha256"]).lower() + actual_sha256 = hashlib.sha256(pcap_data).hexdigest().lower() + if actual_sha256 != expected_sha256: + sys.exit( + f"Error: SHA-256 mismatch (expected {expected_sha256}, got {actual_sha256})." + ) + magic = bytes(pcap_data[:4]) is_valid_magic = any(magic == m for m in PCAP_MAGIC_NUMBERS) diff --git a/edge/spotter/bin/test_fault_injection b/edge/spotter/bin/test_fault_injection index 10b36f9dca..3403d3932a 100755 --- a/edge/spotter/bin/test_fault_injection +++ b/edge/spotter/bin/test_fault_injection @@ -13,7 +13,7 @@ fi echo "=== Spotter Network Resilience & Fault Injection Test Suite ===" -echo "--- Step 1: Verifying StreamsEvents transport structure and sequence ordering ---" +echo "--- Step 1: Verifying DiscoveryUpload transport structure and sequence ordering ---" ( cd "${WORKSPACE_DIR}" PYTHONPATH=clientlib/python/src:gencode/python:. "${WORKSPACE_DIR}/edge/spotter/venv/bin/python3" -m unittest edge.spotter.tests.test_agent.TestSpotterDiscoveryManager -v diff --git a/edge/spotter/bin/test_pcap b/edge/spotter/bin/test_pcap index db43346f6a..d9cf2962fd 100755 --- a/edge/spotter/bin/test_pcap +++ b/edge/spotter/bin/test_pcap @@ -10,7 +10,7 @@ if [[ "${1:-}" == "--help" || "${1:-}" == "-h" ]]; then echo "Validates remote-triggered PCAP packet capture diagnostics over MQTT streaming:" echo "1. Starts Mosquitto broker and udmi-spotter container." echo "2. Generates dummy traffic and publishes a Discovery TRACE configuration (depth: 'trace')." - echo "3. Asserts sequential StreamsEvents chunks over events/streams and parses PCAP magic headers." + echo "3. Asserts sequential DiscoveryUpload chunks over upload/discovery and parses PCAP magic headers." echo "4. Verifies Level 200 status reports in the container's discovery state." echo "" echo "Prerequisites:" @@ -157,12 +157,12 @@ cat < "$TEST_DIR/spotter_config.json" } EOF -echo "Setting up MQTT subscriber on port 1883 for state and stream event messages" +echo "Setting up MQTT subscriber on port 1883 for state and upload/discovery messages" MQTT_OUT="$TEST_DIR/mqtt.out" touch "$MQTT_OUT" mosquitto_sub -h 127.0.0.1 -p 1883 \ -t "/r/ZZ-TRI-FECTA/d/DSN-1/state" \ - -t "/r/ZZ-TRI-FECTA/d/DSN-1/events/streams" \ + -t "/r/ZZ-TRI-FECTA/d/DSN-1/upload/discovery" \ -F "%t %p" > "$MQTT_OUT" & MOSQ_SUB_PID=$! @@ -231,7 +231,7 @@ TRAFFIC_PID=$! echo "Publishing discovery TRACE config to trigger packet capture over MQTT..." mosquitto_pub -h 127.0.0.1 -p 1883 -t "/r/ZZ-TRI-FECTA/d/DSN-1/config" -f "$TEST_DIR/config_payload.json" -q 1 -echo "Waiting for discovery state 'stopped' and stream transfers" +echo "Waiting for discovery state 'stopped' and upload transfers" END_TIME=$((SECONDS + 25)) STATE_STOPPED=0 PCAP_DONE=0 @@ -243,9 +243,9 @@ while [[ $SECONDS -lt $END_TIME ]]; do fi fi - if grep -q '/events/streams' "$MQTT_OUT"; then - TOTAL=$(grep '/events/streams' "$MQTT_OUT" | head -n 1 | sed 's|^[^ ]* ||' | jq -r '.total_chunks') - COUNT=$(grep '/events/streams' "$MQTT_OUT" | wc -l) + if grep -q '/upload/discovery' "$MQTT_OUT"; then + TOTAL=$(grep '/upload/discovery' "$MQTT_OUT" | head -n 1 | sed 's|^[^ ]* ||' | jq -r '.total_chunks') + COUNT=$(grep '/upload/discovery' "$MQTT_OUT" | wc -l) if [[ -n "$TOTAL" && "$TOTAL" != "null" && $COUNT -ge $TOTAL ]]; then PCAP_DONE=1 fi @@ -265,7 +265,7 @@ if [[ $STATE_STOPPED -eq 0 ]]; then exit 1 fi if [[ $PCAP_DONE -eq 0 ]]; then - echo "ERROR: Test timed out waiting for PCAP streaming chunks over MQTT events/streams" + echo "ERROR: Test timed out waiting for PCAP streaming chunks over MQTT upload/discovery" exit 1 fi @@ -277,7 +277,7 @@ if grep '/state' "$MQTT_OUT" | tail -n 1 | sed 's|^[^ ]* ||' | jq -e '.discovery fi echo "Reassembling pcap payload using reassemble_pcap utility" -grep '/events/streams' "$MQTT_OUT" | sed 's|^[^ ]* ||' > "$TEST_DIR/stream_events.json" +grep '/upload/discovery' "$MQTT_OUT" | sed 's|^[^ ]* ||' > "$TEST_DIR/stream_events.json" "${SCRIPT_DIR}/reassemble_pcap" "$TEST_DIR/stream_events.json" "$TEST_DIR/assembled.pcap" echo "Validating pcap payload size" diff --git a/edge/spotter/src/manager/discovery.py b/edge/spotter/src/manager/discovery.py index 91185b9c01..5bb1625a91 100644 --- a/edge/spotter/src/manager/discovery.py +++ b/edge/spotter/src/manager/discovery.py @@ -2,11 +2,12 @@ Extends standard DiscoveryManager to natively handle both active protocol sweeps (BACnet, Ether, Passive) and TRACE-level packet streaming (PCAP) over -events/streams. +upload/discovery. """ import base64 from datetime import datetime, timedelta, timezone +import hashlib import logging import time from typing import Any, Dict, Optional @@ -16,10 +17,10 @@ from udmi.schema import ( Config, DiscoveryEvents, + DiscoveryUpload, Entry, FamilyDiscoveryConfig, FamilyDiscoveryState, - StreamsEvents, ) from udmi.schema.common import Depth from udmi.schema.state_discovery_family import Phase as DiscoveryPhase @@ -61,7 +62,7 @@ class SpotterDiscoveryManager(DiscoveryManager): """Unified Discovery Manager for Spotter. Extends standard DiscoveryManager to handle active protocol sweeps and - TRACE packet streaming over events/streams with safety circuit breaker checks + TRACE packet streaming over upload/discovery with safety circuit breaker checks and UDMI discovery phase lifecycle tracking (pending/active/stopped). """ @@ -364,7 +365,7 @@ def _run_scan(self, family: str, provider: Any) -> None: self.trigger_state_update() def _run_trace_capture(self, family: str, fam_config: Any) -> None: - """Executes PCAP capture and streams chunks over events/streams.""" + """Executes PCAP capture and streams chunks over upload/discovery.""" f_state = self._discovery_state.families.get(family) if not f_state: f_state = FamilyDiscoveryState() @@ -483,7 +484,7 @@ def _run_trace_capture(self, family: str, fam_config: Any) -> None: full_data = b"".join(captured_chunks) LOGGER.info( - "Capture complete (%d bytes). Emitting StreamsEvents...", + "Capture complete (%d bytes). Emitting DiscoveryUpload chunks...", len(full_data), ) chunk_size = 128 * 1024 # 128KB chunks @@ -492,38 +493,67 @@ def _run_trace_capture(self, family: str, fam_config: Any) -> None: (total_bytes + chunk_size - 1) // chunk_size if total_bytes > 0 else 1 ) session_id = f"trace-{family}-{int(time.time())}" + gen_str = f_state.generation or _format_generation( + datetime.now(timezone.utc) + ) + + start_upload = DiscoveryUpload( + timestamp=datetime.now(timezone.utc).isoformat(), + version=UDMI_VERSION, + family=family, + generation=gen_str, + session_id=session_id, + event_no=0, + total_chunks=total_chunks, + ) + self.publish_upload(start_upload, "discovery") for idx in range(total_chunks): start = idx * chunk_size end = min(start + chunk_size, total_bytes) chunk_data = full_data[start:end] b64_data = base64.b64encode(chunk_data).decode() + event_no = idx + 1 - chunk_event = StreamsEvents( + chunk_upload = DiscoveryUpload( timestamp=datetime.now(timezone.utc).isoformat(), version=UDMI_VERSION, + family=family, + generation=gen_str, session_id=session_id, - event_no=idx, + event_no=event_no, chunk_index=idx, total_chunks=total_chunks, data=b64_data, ) - self.publish_event(chunk_event, "streams") + self.publish_upload(chunk_upload, "discovery") LOGGER.info( - "Published stream chunk %d/%d (event_no: %d, %d bytes)", + "Published upload chunk %d/%d (event_no: %d, %d bytes)", idx + 1, total_chunks, - idx, + event_no, len(chunk_data), ) + eof_upload = DiscoveryUpload( + timestamp=datetime.now(timezone.utc).isoformat(), + version=UDMI_VERSION, + family=family, + generation=gen_str, + session_id=session_id, + event_no=-(total_chunks + 1), + total_chunks=total_chunks, + sha256=hashlib.sha256(full_data).hexdigest(), + ) + self.publish_upload(eof_upload, "discovery") + f_state.phase = DiscoveryPhase.stopped f_state.active_count = total_chunks f_state.status = Entry( category="discovery.family", level=200, message=( - f"Trace capture complete. {total_chunks} stream chunks emitted." + f"Trace capture complete. {total_chunks} upload chunks emitted." ), ) diff --git a/edge/spotter/tests/test_agent.py b/edge/spotter/tests/test_agent.py index 713006941b..8469e0d343 100644 --- a/edge/spotter/tests/test_agent.py +++ b/edge/spotter/tests/test_agent.py @@ -452,16 +452,32 @@ def test_run_trace_worker_streaming(self, mock_capture): self.manager._discovery_state.families["ether"] = MagicMock() self.manager._run_trace_capture("ether", fam_config) - self.assertTrue(self.mock_dispatcher.publish_event.called) - call_args = self.mock_dispatcher.publish_event.call_args[0] - channel = call_args[0] - event_model = call_args[1] - - self.assertEqual(channel, "events/streams") - self.assertEqual(event_model.event_no, 0) - self.assertEqual(event_model.chunk_index, 0) - self.assertEqual(event_model.total_chunks, 1) - self.assertIn("trace-ether-", event_model.session_id) + self.assertEqual(self.mock_dispatcher.publish_event.call_count, 3) + calls = self.mock_dispatcher.publish_event.call_args_list + + # 1. Start marker (event_no = 0) + self.assertEqual(calls[0][0][0], "upload/discovery") + start_model = calls[0][0][1] + self.assertEqual(start_model.event_no, 0) + self.assertEqual(start_model.family, "ether") + self.assertIn("trace-ether-", start_model.session_id) + + # 2. Data chunk (event_no = 1, chunk_index = 0) + self.assertEqual(calls[1][0][0], "upload/discovery") + chunk_model = calls[1][0][1] + self.assertEqual(chunk_model.event_no, 1) + self.assertEqual(chunk_model.chunk_index, 0) + self.assertEqual(chunk_model.total_chunks, 1) + self.assertEqual(chunk_model.session_id, start_model.session_id) + + # 3. Terminal EOF marker (event_no = -2, sha256) + self.assertEqual(calls[2][0][0], "upload/discovery") + eof_model = calls[2][0][1] + self.assertEqual(eof_model.event_no, -2) + self.assertEqual( + eof_model.sha256, + hashlib.sha256(b"MOCK_PACKET_HEADERMOCK_PACKET_BODY").hexdigest(), + ) f_state = self.manager._discovery_state.families["ether"] self.assertEqual(f_state.phase, DiscoveryPhase.stopped) diff --git a/gencode/docs/config.html b/gencode/docs/config.html index 765a6018a1..ec4d95da6c 100644 --- a/gencode/docs/config.html +++ b/gencode/docs/config.html @@ -2698,7 +2698,7 @@

Must be one of:

-
  • "_iot_endpoint_config"
+
  • "_iot_endpoint_config"
  • "_bacnet_sc_config"
  • "_discovery_auth"
diff --git a/gencode/docs/configuration_endpoint.html b/gencode/docs/configuration_endpoint.html index a7ec061281..cd3dae2418 100644 --- a/gencode/docs/configuration_endpoint.html +++ b/gencode/docs/configuration_endpoint.html @@ -1016,6 +1016,238 @@

+ + + + +
+
+
+

+ +

+
+ +
+
+ + Type: string
+

Pre-signed JWT token or credential string

+
+ + + + + + +
+
+
+
+ + + + +
+
+
+

+ +

+
+ +
+
+ + Type: object
+ No Additional Properties + + + + + + +
+
+
+

+ +

+
+ +
+
+ + Type: string
+

PEM-encoded private key material for mTLS authentication

+
+ + + + + + +
+
+
+
+
+
+
+

+ +

+
+ +
+
+ + Type: string
+

PEM-encoded client certificate for mTLS authentication

+
+ + + + + + +
+
+
+
+
+
+
+

+ +

+
+ +
+
+ + Type: string
+

PEM-encoded CA certificate bundle for peer verification

+
+ + + + + +
@@ -1163,6 +1395,44 @@

Example:

"2019-01-17T14:02:29.364Z"
 
+
+

+
+
+
+
+
+
+

+ +

+
+ +
+
+ + Type: stringFormat: date-time
+

Timestamp when the provisioned credential or endpoint expires

+
+ + + + + +
+
Example:
+
"2019-01-17T14:02:29.364Z"
+
diff --git a/gencode/docs/configuration_execution.html b/gencode/docs/configuration_execution.html index c05580289a..6750ae0e3c 100644 --- a/gencode/docs/configuration_execution.html +++ b/gencode/docs/configuration_execution.html @@ -1678,6 +1678,273 @@

+

+
+
+ +
+
+
+

+ +

+
+ +
+
+ + Type: string
+

Pre-signed JWT token or credential string

+
+ + + + + + +
+
+
+
+ + + + +
+
+
+

+ +

+
+ +
+
+ + Type: object
+ No Additional Properties + + + + + + +
+
+
+

+ +

+
+ +
+
+ + Type: string
+

PEM-encoded private key material for mTLS authentication

+
+ + + + + + +
+
+
+
+
+
+
+

+ +

+
+ +
+
+ + Type: string
+

PEM-encoded client certificate for mTLS authentication

+
+ + + + + + +
+
+
+
+
+
+
+

+ +

+
+ +
+
+ + Type: string
+

PEM-encoded CA certificate bundle for peer verification

+
+ + + + + +
@@ -1853,6 +2120,51 @@

Example:

"2019-01-17T14:02:29.364Z"
 
+
+

+
+
+
+
+
+
+

+ +

+
+ +
+
+ + Type: stringFormat: date-time
+

Timestamp when the provisioned credential or endpoint expires

+
+ + + + + +
+
Example:
+
"2019-01-17T14:02:29.364Z"
+
@@ -3077,6 +3389,273 @@

+

+
+
+ +
+
+
+

+ +

+
+ +
+
+ + Type: string
+

Pre-signed JWT token or credential string

+
+ + + + + + +
+
+
+
+ + + + +
+
+
+

+ +

+
+ +
+
+ + Type: object
+ No Additional Properties + + + + + + +
+
+
+

+ +

+
+ +
+
+ + Type: string
+

PEM-encoded private key material for mTLS authentication

+
+ + + + + + +
+
+
+
+
+
+
+

+ +

+
+ +
+
+ + Type: string
+

PEM-encoded client certificate for mTLS authentication

+
+ + + + + + +
+
+
+
+
+
+
+

+ +

+
+ +
+
+ + Type: string
+

PEM-encoded CA certificate bundle for peer verification

+
+ + + + + +
@@ -3252,6 +3831,51 @@

Example:

"2019-01-17T14:02:29.364Z"
 
+
+

+
+
+
+
+
+
+

+ +

+
+ +
+
+ + Type: stringFormat: date-time
+

Timestamp when the provisioned credential or endpoint expires

+
+ + + + + +
+
Example:
+
"2019-01-17T14:02:29.364Z"
+
diff --git a/gencode/docs/configuration_pod.html b/gencode/docs/configuration_pod.html index d59cc3391c..ffe62f5509 100644 --- a/gencode/docs/configuration_pod.html +++ b/gencode/docs/configuration_pod.html @@ -1436,6 +1436,273 @@

+

+
+
+ +
+
+
+

+ +

+
+ +
+
+ + Type: string
+

Pre-signed JWT token or credential string

+
+ + + + + + +
+
+
+
+ + + + +
+
+
+

+ +

+
+ +
+
+ + Type: object
+ No Additional Properties + + + + + + +
+
+
+

+ +

+
+ +
+
+ + Type: string
+

PEM-encoded private key material for mTLS authentication

+
+ + + + + + +
+
+
+
+
+
+
+

+ +

+
+ +
+
+ + Type: string
+

PEM-encoded client certificate for mTLS authentication

+
+ + + + + + +
+
+
+
+
+
+
+

+ +

+
+ +
+
+ + Type: string
+

PEM-encoded CA certificate bundle for peer verification

+
+ + + + + +
@@ -1611,6 +1878,51 @@

Example:

"2019-01-17T14:02:29.364Z"
 
+
+

+
+
+
+
+
+
+

+ +

+
+ +
+
+ + Type: stringFormat: date-time
+

Timestamp when the provisioned credential or endpoint expires

+
+ + + + + +
+
Example:
+
"2019-01-17T14:02:29.364Z"
+
@@ -3076,26 +3388,18 @@

- - - - - - - - -
+
-
+

- +

-
+
Type: string
-

Path to the CA certificate file

+ auth_provider + + + + jwt + + + + token
Type: string
+

Pre-signed JWT token or credential string

@@ -3132,18 +3450,22 @@

-
+
+
+
+
+
-
+

- +

-
+
Type: string
-

Path to the client certificate file

-
+ auth_provider + + + + mtls
Type: object
+ No Additional Properties -
-
-
-
-
+
-
+

- +

-
+
Type: string
-

Path to the client private key file

+ auth_provider + + + + mtls + + + + private_key
Type: string
+

PEM-encoded private key material for mTLS authentication

@@ -3228,18 +3566,18 @@

-
+
-
+

- +

-
+
Type: stringFormat: date-time
-

The timestamp of the endpoint generation

+ auth_provider + + + + mtls + + + + certificate
Type: string
+

PEM-encoded client certificate for mTLS authentication

-
-
Example:
-
"2019-01-17T14:02:29.364Z"
-
-
-
-
-
-
-
-
-
-
+
-
+
-
+

- +

-
+
Type: object
- - - - - - - -
-
-
-

- -

-
- -
-

- -

-

All properties whose name matches the following regular expression must respect the following conditions

- Property name regular expression: ^[a-z][a-z0-9]*(_[a-z0-9]+)*$ -
- -

Endpoint Configuration

Type: object
-

Parameters to define a message endpoint

+ auth_provider + + + + mtls + + + + ca_certificate
Type: string
+

PEM-encoded CA certificate bundle for peer verification

- - No Additional Properties -
+
+
+
+
+
+
+
+
+
+
+
+ +
-
+

- +

-
+
Type: string
-

Friendly name for this flow (debugging and diagnostics)

+ ca_file
Type: string
+

Path to the CA certificate file

@@ -3409,18 +3746,18 @@

-
+
-
+

- +

-
+
Type: enum (of string)
-
-

Must be one of:

-
  • "local"
  • "pubsub"
  • "file"
  • "trace"
  • "mqtt"
-
+ cert_file
Type: string
+

Path to the client certificate file

+
@@ -3459,18 +3794,18 @@

Must be one of:

-
+
-
+

- +

-
+
Type: enum (of string)
-
-

Must be one of:

-
  • "ssl"
  • "tcp"
-
+ key_file
Type: string
+

Path to the client private key file

+
@@ -3509,18 +3842,18 @@

Must be one of:

-
+
-
+

- +

-
+
Type: string
- + generation
Type: stringFormat: date-time
+

The timestamp of the endpoint generation

+
- +
+
Example:
+
"2019-01-17T14:02:29.364Z"
+
+
-
+
-
+

- +

-
+
Type: string
-

Simple payload template for simple injection use cases

+ expiry
Type: stringFormat: date-time
+

Timestamp when the provisioned credential or endpoint expires

- +
+
Example:
+
"2019-01-17T14:02:29.364Z"
+
+
-
+
+
+
+
+
+
+
+
+
-
+

- +

-
+
Type: object
+ + + + + + + +
+
+
+

+ +

+
+ +
+

+ +

+

All properties whose name matches the following regular expression must respect the following conditions

+ Property name regular expression: ^[a-z][a-z0-9]*(_[a-z0-9]+)*$ +
+ + Type: string
-

Error message container for capturing errors during parsing/handling

+ Endpoint Configuration

Endpoint Configuration

Type: object
+

Parameters to define a message endpoint

+ + No Additional Properties -
-
-
-
-
+
-
+

- +

-
+
Type: integer
- + name
Type: string
+

Friendly name for this flow (debugging and diagnostics)

+
@@ -3699,18 +4075,18 @@

-
+
-
+

- +

-
+
Type: integer
-

Delay waiting for config message on start, 0 for default, <0 to disable

-
+ protocol
Type: enum (of string)
+
+

Must be one of:

+
  • "local"
  • "pubsub"
  • "file"
  • "trace"
  • "mqtt"
+
@@ -3747,18 +4125,18 @@

-
+
-
+

- +

-
+
Type: string
- + transport
Type: enum (of string)
+
+

Must be one of:

+
  • "ssl"
  • "tcp"
+
@@ -3794,18 +4175,18 @@

-
+
-
+

- +

-
+
Type: string
-

Prefix for message topics

-
Must match regular expression: ^[-_/a-zA-Z0-9]+$ + hostname
Type: string
+ @@ -3842,18 +4222,18 @@

-
+
-
+

- +

-
+
Type: string
-

Id for the receiving message channel

-
Must match regular expression: ^[-_/a-zA-Z0-9#]+$ + payload
Type: string
+

Simple payload template for simple injection use cases

+
@@ -3890,18 +4270,18 @@

-
+
-
+

- +

-
+
Type: string
-

Id for the sending messages channel

-
Must match regular expression: ^[-_/a-zA-Z0-9#]+$ + error
Type: string
+

Error message container for capturing errors during parsing/handling

+
@@ -3938,18 +4318,18 @@

-
+
-
+

- +

-
+
Type: string
-

Id for a side-car message channel

-
Must match regular expression: ^[-_/a-zA-Z0-9#]+$ + port
Type: integer
+ @@ -3986,18 +4365,18 @@

-
+
-
+

- -

+ +

-
+
Type: string
- + config_sync_sec
Type: integer
+

Delay waiting for config message on start, 0 for default, <0 to disable

+
@@ -4033,18 +4413,18 @@

-
+
-
+

- +

-
+
Type: string
+ client_id
Type: string
@@ -4080,18 +4460,18 @@

-
+
-
+

- +

-
+
Type: string
-

Indicator if this endpoint should be active (null or non-empty)

-
+ topic_prefix
Type: string
+

Prefix for message topics

+
Must match regular expression: ^[-_/a-zA-Z0-9]+$ @@ -4128,18 +4508,18 @@

-
+
-
+

- +

-
+
Type: boolean
-

True if config messages should not be acked (lower QOS)

-
+ recv_id
Type: string
+

Id for the receiving message channel

+
Must match regular expression: ^[-_/a-zA-Z0-9#]+$ @@ -4176,18 +4556,18 @@

-
+
-
+

- +

-
+
Type: integer
-

Queue capacity for limiting pipes

-
+ send_id
Type: string
+

Id for the sending messages channel

+
Must match regular expression: ^[-_/a-zA-Z0-9#]+$ @@ -4224,18 +4604,18 @@

-
+
-
+

- +

-
+
Type: integer
-

Artifical publish delay for testing

-
+ side_id
Type: string
+

Id for a side-car message channel

+
Must match regular expression: ^[-_/a-zA-Z0-9#]+$ @@ -4272,18 +4652,18 @@

-
+
-
+

- +

-
+
Type: integer
-

Rate for periodic task execution

-
+ gatewayId
Type: string
+ @@ -4320,18 +4699,18 @@

-
+
-
+

- +

-
+
Type: object
+ deviceId
Type: string
@@ -4367,18 +4746,18 @@

-
+
-
+

- +

-
+
Type: string
- + enabled
Type: string
+

Indicator if this endpoint should be active (null or non-empty)

+
@@ -4414,18 +4794,18 @@

-
+
-
+

- +

-
+
Type: object
- No Additional Properties + noConfigAck
Type: boolean
+

True if config messages should not be acked (lower QOS)

+
-
+
+
+
+
+
-
+

- +

-
+
Type: object
- No Additional Properties + capacity
Type: integer
+

Queue capacity for limiting pipes

+
-
+
+
+
+
+
-
+

- +

-
+
Type: string
- + publish_delay_sec
Type: integer
+

Artifical publish delay for testing

+
@@ -4568,18 +4938,18 @@

-
+
-
+

- +

-
+
Type: string
- + periodic_sec
Type: integer
+

Rate for periodic task execution

+
@@ -4629,22 +4986,18 @@

-
-
-
-
-
+
-
+

- +

-
+
Type: object
- No Additional Properties + keyBytes
Type: object
+ -
+
+
+
+
+
-
+

- +

-
+
Type: string
+ algorithm
Type: string
@@ -4744,26 +5080,18 @@

-
-
-
-
-
-
-
-
-
+
-
+

- +

-
+
Type: string
-

Path to the CA certificate file

-
+ auth_provider
Type: object
+ No Additional Properties -
-
-
-
-
+
-
+

- +

-
+
Type: string
-

Path to the client certificate file

-
+ auth_provider + + + + basic
Type: object
+ No Additional Properties -
-
-
-
-
+
-
+

- +

-
+
Type: string
-

Path to the client private key file

-
+ auth_provider + + + + basic + + + + username
Type: string
+ @@ -4896,18 +5234,18 @@

-
+
-
+

- +

-
+
Type: stringFormat: date-time
-

The timestamp of the endpoint generation

-
+ auth_provider + + + + basic + + + + password
Type: string
+ -
-
Example:
-
"2019-01-17T14:02:29.364Z"
-
-
-
-
-
-
+
@@ -4956,18 +5299,18 @@

-
+
-
+

- +

-
+
Type: object
- - - - - - - -
-
-
-

- -

-
- -
-

- -

-

All properties whose name matches the following regular expression must respect the following conditions

- Property name regular expression: ^[a-z][a-z0-9]*(_[a-z0-9]+)*$ -
- -

Bridge Pod Configuration

Type: object
-

Parameters to define a bridge between message domains

-
- - No Additional Properties + auth_provider + + + + jwt
Type: object
+ No Additional Properties -
+
-
+

- +

-
+
Type: string
+ auth_provider + + + + jwt + + + + audience
Type: string
@@ -5076,18 +5410,18 @@

-
+
-
+

- +

-
+

Endpoint Configuration

Type: object
-

Parameters to define a message endpoint

+ auth_provider + + + + jwt + + + + token
Type: string
+

Pre-signed JWT token or credential string

- - No Additional Properties -
+
+
+
+
+
+
+
+
+
-
+

- +

-
+
Type: string
-

Friendly name for this flow (debugging and diagnostics)

-
+ mtls
Type: object
+ No Additional Properties -
-
-
-
-
+
-
+

- +

-
+
Type: enum (of string)
-
-

Must be one of:

-
  • "local"
  • "pubsub"
  • "file"
  • "trace"
  • "mqtt"
-
+ mtls + + + + private_key
Type: string
+

PEM-encoded private key material for mTLS authentication

+
@@ -5234,18 +5588,18 @@

Must be one of:

-
+
-
+

- +

-
+
Type: enum (of string)
-
-

Must be one of:

-
  • "ssl"
  • "tcp"
-
+ mtls + + + + certificate
Type: string
+

PEM-encoded client certificate for mTLS authentication

+
@@ -5291,18 +5650,18 @@

Must be one of:

-
+
-
+

- +

-
+
Type: string
- + mtls + + + + ca_certificate
Type: string
+

PEM-encoded CA certificate bundle for peer verification

+
@@ -5345,18 +5712,26 @@

-
+
+
+
+
+
+
+
+
+
-
+

- +

-
+
Type: string
-

Simple payload template for simple injection use cases

+ ca_file
Type: string
+

Path to the CA certificate file

@@ -5400,18 +5768,18 @@

-
+
-
+

- +

-
+
Type: string
-

Error message container for capturing errors during parsing/handling

+ cert_file
Type: string
+

Path to the client certificate file

@@ -5455,18 +5816,18 @@

-
+
-
+

- +

-
+
Type: integer
- + key_file
Type: string
+

Path to the client private key file

+
@@ -5509,18 +5864,18 @@

-
+
-
+

- +

-
+
Type: integer
-

Delay waiting for config message on start, 0 for default, <0 to disable

+ generation
Type: stringFormat: date-time
+

The timestamp of the endpoint generation

- +
+
Example:
+
"2019-01-17T14:02:29.364Z"
+
+
-
+
-
+

- +

-
+
Type: stringFormat: date-time
+

Timestamp when the provisioned credential or endpoint expires

+
+ + + + + +
+
Example:
+
"2019-01-17T14:02:29.364Z"
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+

+ +

+
+ +
+
+ + Type: string
+ bridges
Type: object
@@ -5614,23 +6005,24 @@

-

-
-
-
-
+
-
+

- +

-
-
+
+

+ +

+

All properties whose name matches the following regular expression must respect the following conditions

+ Property name regular expression: ^[a-z][a-z0-9]*(_[a-z0-9]+)*$ +

Bridge Pod Configuration

Type: object
+

Parameters to define a bridge between message domains

+
+ + No Additional Properties + + + + + + +
+
+
+

+ +

+
+ +
+
+ + Type: string
-

Prefix for message topics

-
Must match regular expression: ^[-_/a-zA-Z0-9]+$ + Bridge Pod Configuration + + + + enabled
Type: string
+ @@ -5673,18 +6096,18 @@

-
+
-
+

- +

-
+
Type: string
-

Id for the receiving message channel

-
Must match regular expression: ^[-_/a-zA-Z0-9#]+$ + from

Endpoint Configuration

Type: object
+

Parameters to define a message endpoint

+
+ + No Additional Properties -
-
-
-
-
+
-
+

- +

-
+
Type: string
-

Id for the sending messages channel

-
Must match regular expression: ^[-_/a-zA-Z0-9#]+$ + name
Type: string
+

Friendly name for this flow (debugging and diagnostics)

+
@@ -5783,18 +6197,18 @@

-
+
-
+

- +

-
+
Type: string
-

Id for a side-car message channel

-
Must match regular expression: ^[-_/a-zA-Z0-9#]+$ + protocol
Type: enum (of string)
+
+

Must be one of:

+
  • "local"
  • "pubsub"
  • "file"
  • "trace"
  • "mqtt"
+
@@ -5838,18 +6254,18 @@

-
+
-
+

- +

-
+
Type: string
- + transport
Type: enum (of string)
+
+

Must be one of:

+
  • "ssl"
  • "tcp"
+
@@ -5892,18 +6311,18 @@

-
+
-
+

- +

-
+
Type: string
+ hostname
Type: string
@@ -5946,18 +6365,18 @@

-
+
-
+

- +

-
+
Type: string
-

Indicator if this endpoint should be active (null or non-empty)

+ payload
Type: string
+

Simple payload template for simple injection use cases

@@ -6001,18 +6420,18 @@

-
+
-
+

- +

-
+
Type: boolean
-

True if config messages should not be acked (lower QOS)

+ error
Type: string
+

Error message container for capturing errors during parsing/handling

@@ -6056,18 +6475,18 @@

-
+
-
+

- +

-
+
Type: integer
-

Queue capacity for limiting pipes

-
+ port
Type: integer
+ @@ -6111,18 +6529,18 @@

-
+
-
+

- +

-
+
Type: integer
-

Artifical publish delay for testing

+ config_sync_sec
Type: integer
+

Delay waiting for config message on start, 0 for default, <0 to disable

@@ -6166,18 +6584,18 @@

-
+
-
+

- +

-
+
Type: integer
-

Rate for periodic task execution

-
+ client_id
Type: string
+ @@ -6221,18 +6638,18 @@

-
+
-
+

- +

-
+
Type: object
- + topic_prefix
Type: string
+

Prefix for message topics

+
Must match regular expression: ^[-_/a-zA-Z0-9]+$ @@ -6275,18 +6693,18 @@

-
+
-
+

- +

-
+
Type: string
- + recv_id
Type: string
+

Id for the receiving message channel

+
Must match regular expression: ^[-_/a-zA-Z0-9#]+$ @@ -6329,18 +6748,18 @@

-
+
-
+

- +

-
+
Type: object
- No Additional Properties + send_id
Type: string
+

Id for the sending messages channel

+
Must match regular expression: ^[-_/a-zA-Z0-9#]+$ -
+
+
+
+
+
-
+

- +

-
+
Type: object
- No Additional Properties + side_id
Type: string
+

Id for a side-car message channel

+
Must match regular expression: ^[-_/a-zA-Z0-9#]+$ -
+
+
+
+
+
-
+

- +

-
+
Type: string
+ gatewayId
Type: string
@@ -6504,18 +6912,18 @@

-
+
-
+

- +

-
+
Type: string
+ deviceId
Type: string
@@ -6572,22 +6966,18 @@

-
-
-
-
-
+
-
+

- +

-
+
Type: object
- No Additional Properties + enabled
Type: string
+

Indicator if this endpoint should be active (null or non-empty)

+
-
+
+
+
+
+
-
+

- +

-
+
Type: string
- + noConfigAck
Type: boolean
+

True if config messages should not be acked (lower QOS)

+
@@ -6701,26 +7076,18 @@

-
-
-
-
-
-
-
-
-
+
-
+

- +

-
+
Type: string
-

Path to the CA certificate file

+ capacity
Type: integer
+

Queue capacity for limiting pipes

@@ -6764,18 +7131,18 @@

-
+
-
+

- +

-
+
Type: string
-

Path to the client certificate file

+ publish_delay_sec
Type: integer
+

Artifical publish delay for testing

@@ -6819,18 +7186,18 @@

-
+
-
+

- +

-
+
Type: string
-

Path to the client private key file

+ periodic_sec
Type: integer
+

Rate for periodic task execution

@@ -6874,18 +7241,18 @@

-
+
-
+

- +

-
+
Type: stringFormat: date-time
-

The timestamp of the endpoint generation

-
+ keyBytes
Type: object
+ -
-
Example:
-
"2019-01-17T14:02:29.364Z"
-
-
-
-
-
-
+
-
+
-
+

- +

-
+

Endpoint Configuration

Type: object
-

Parameters to define a message endpoint

-
+ from + + + + algorithm
Type: string
- No Additional Properties -
+
+
+
+
+
-
+

- +

-
+
Type: string
-

Friendly name for this flow (debugging and diagnostics)

-
+ auth_provider
Type: object
+ No Additional Properties -
-
-
-
-
+
-
+

- +

-
+
Type: enum (of string)
-
-

Must be one of:

-
  • "local"
  • "pubsub"
  • "file"
  • "trace"
  • "mqtt"
-
+ auth_provider + + + + basic
Type: object
+ No Additional Properties -
-
-
-
-
+
-
+

- +

-
+
Type: enum (of string)
-
-

Must be one of:

-
  • "ssl"
  • "tcp"
-
+ auth_provider + + + + basic + + + + username
Type: string
+ @@ -7152,18 +7524,18 @@

Must be one of:

-
+
-
+

- +

-
+
Type: string
+ auth_provider + + + + basic + + + + password
Type: string
@@ -7206,18 +7592,22 @@

-
+
+
+
+
+
-
+

- +

-
+
Type: string
-

Simple payload template for simple injection use cases

-
+ auth_provider + + + + jwt
Type: object
+ No Additional Properties -
-
-
-
-
+
-
+

- +

-
+
Type: string
-

Error message container for capturing errors during parsing/handling

-
+ auth_provider + + + + jwt + + + + audience
Type: string
+ @@ -7316,18 +7721,18 @@

-
+
-
+

- +

-
+
Type: integer
- + auth_provider + + + + jwt + + + + token
Type: string
+

Pre-signed JWT token or credential string

+
@@ -7370,18 +7790,22 @@

-
+
+
+
+
+
-
+

- +

-
+
Type: integer
-

Delay waiting for config message on start, 0 for default, <0 to disable

-
+ auth_provider + + + + mtls
Type: object
+ No Additional Properties -
-
-
-
-
+
-
+

- +

-
+
Type: string
- + auth_provider + + + + mtls + + + + private_key
Type: string
+

PEM-encoded private key material for mTLS authentication

+
@@ -7479,18 +7920,18 @@

-
+
-
+

- +

-
+
Type: string
-

Prefix for message topics

-
Must match regular expression: ^[-_/a-zA-Z0-9]+$ - - - - - + auth_provider + + + + mtls + + + + certificate
Type: string
+

PEM-encoded client certificate for mTLS authentication

+
+ + + + +
-
+
-
+

- +

-
+
Type: string
-

Id for the receiving message channel

-
Must match regular expression: ^[-_/a-zA-Z0-9#]+$ + auth_provider + + + + mtls + + + + ca_certificate
Type: string
+

PEM-encoded CA certificate bundle for peer verification

+
@@ -7589,18 +8058,26 @@

-
+
+
+
+
+
+
+
+
+
-
+

- +

-
+
Type: string
-

Id for the sending messages channel

-
Must match regular expression: ^[-_/a-zA-Z0-9#]+$ + ca_file
Type: string
+

Path to the CA certificate file

+
@@ -7644,18 +8121,18 @@

-
+
-
+

- +

-
+
Type: string
-

Id for a side-car message channel

-
Must match regular expression: ^[-_/a-zA-Z0-9#]+$ + cert_file
Type: string
+

Path to the client certificate file

+
@@ -7699,18 +8176,18 @@

-
+
-
+

- +

-
+
Type: string
- + key_file
Type: string
+

Path to the client private key file

+
@@ -7753,18 +8231,18 @@

-
+
-
+

- +

-
+
Type: string
- + generation
Type: stringFormat: date-time
+

The timestamp of the endpoint generation

+
- +
+
Example:
+
"2019-01-17T14:02:29.364Z"
+
+
-
+
-
+

- +

-
+
Type: string
-

Indicator if this endpoint should be active (null or non-empty)

+ expiry
Type: stringFormat: date-time
+

Timestamp when the provisioned credential or endpoint expires

- +
+
Example:
+
"2019-01-17T14:02:29.364Z"
+
+
-
+
+
+
+
+
-
+

- +

-
+
Type: boolean
-

True if config messages should not be acked (lower QOS)

+ morf

Endpoint Configuration

Type: object
+

Parameters to define a message endpoint

+ + No Additional Properties -
-
-
-
-
+
-
+

- +

-
+
Type: integer
-

Queue capacity for limiting pipes

+ name
Type: string
+

Friendly name for this flow (debugging and diagnostics)

@@ -7972,18 +8454,18 @@

-
+
-
+

- +

-
+
Type: integer
-

Artifical publish delay for testing

-
+ protocol
Type: enum (of string)
+
+

Must be one of:

+
  • "local"
  • "pubsub"
  • "file"
  • "trace"
  • "mqtt"
+
@@ -8027,18 +8511,18 @@

-
+
-
+

- +

-
+
Type: integer
-

Rate for periodic task execution

-
- - - + transport
Type: enum (of string)
+
+

Must be one of:

+
  • "ssl"
  • "tcp"
+
+ + + @@ -8082,18 +8568,18 @@

-
+
-
+

- +

-
+
Type: object
+ hostname
Type: string
@@ -8136,18 +8622,18 @@

-
+
-
+

- +

-
+
Type: string
- + payload
Type: string
+

Simple payload template for simple injection use cases

+
@@ -8190,18 +8677,18 @@

-
+
-
+

- +

-
+
Type: object
- No Additional Properties + error
Type: string
+

Error message container for capturing errors during parsing/handling

+
-
+
+
+
+
+
-
+

- +

-
+
Type: object
- No Additional Properties + port
Type: integer
+ -
+
+
+
+
+
-
+

- +

-
+
Type: string
- + config_sync_sec
Type: integer
+

Delay waiting for config message on start, 0 for default, <0 to disable

+
@@ -8365,18 +8841,18 @@

-
+
-
+

- +

-
+
Type: string
+ client_id
Type: string
@@ -8433,22 +8895,18 @@

-
-
-
-
-
+
-
+

- +

-
+
Type: object
- No Additional Properties + topic_prefix
Type: string
+

Prefix for message topics

+
Must match regular expression: ^[-_/a-zA-Z0-9]+$ -
+
+
+
+
+
-
+

- +

-
+
Type: string
- + recv_id
Type: string
+

Id for the receiving message channel

+
Must match regular expression: ^[-_/a-zA-Z0-9#]+$ @@ -8562,26 +9005,18 @@

-
-
-
-
-
-
-
-
-
+
-
+

- +

-
+
Type: string
-

Path to the CA certificate file

-
+ send_id
Type: string
+

Id for the sending messages channel

+
Must match regular expression: ^[-_/a-zA-Z0-9#]+$ @@ -8625,18 +9060,18 @@

-
+
-
+

- +

-
+
Type: string
-

Path to the client certificate file

-
+ side_id
Type: string
+

Id for a side-car message channel

+
Must match regular expression: ^[-_/a-zA-Z0-9#]+$ @@ -8680,18 +9115,18 @@

-
+
-
+

- +

-
+
Type: string
-

Path to the client private key file

-
+ gatewayId
Type: string
+ @@ -8735,18 +9169,18 @@

-
+
-
+

- +

-
+
Type: stringFormat: date-time
-

The timestamp of the endpoint generation

-
+ deviceId
Type: string
+ -
-
Example:
-
"2019-01-17T14:02:29.364Z"
-
-
-
-
-
-
-
-
-
-
-
-
-
-
+
-
+
-
+

- +

-
+

Iot Access Providers

Type: object
- No Additional Properties - - - - - - -
-
-
-

- -

-
- -
-

- -

-

All properties whose name matches the following regular expression must respect the following conditions

- Property name regular expression: ^[a-z]+$ -
- -

Iot Access

Type: object
- - - No Additional Properties + morf + + + + enabled
Type: string
+

Indicator if this endpoint should be active (null or non-empty)

+
-
+
+
+
+
+
-
+

- +

-
+
Type: string
- + morf + + + + noConfigAck
Type: boolean
+

True if config messages should not be acked (lower QOS)

+
@@ -8925,18 +9333,18 @@

-
+
-
+

- +

-
+

Iot Provider

Type: enum (of string)
- - -
-

Must be one of:

-
  • "local"
  • "dynamic"
  • "implicit"
  • "zanzara"
  • "pubsub"
  • "mqtt"
  • "gbos"
  • "gref"
  • "etcd"
  • "jwt"
  • "clearblade"
-
+ morf + + + + capacity
Type: integer
+

Queue capacity for limiting pipes

+
@@ -8977,18 +9388,18 @@

Must be one of:

-
+
-
+

- +

-
+
Type: string
- + morf + + + + publish_delay_sec
Type: integer
+

Artifical publish delay for testing

+
@@ -9024,18 +9443,18 @@

-
+
-
+

- +

-
+
Type: integer
- - - - - - + morf + + + + periodic_sec
Type: integer
+

Rate for periodic task execution

+
+ + + + +
-
+
-
+

- +

-
+
Type: string
+ morf + + + + keyBytes
Type: object
@@ -9118,18 +9552,18 @@

-
+
-
+

- +

-
+

Endpoint Configuration

Type: object
-

Parameters to define a message endpoint

-
+ morf + + + + algorithm
Type: string
- No Additional Properties -
+
+
+
+
+
-
+

- +

-
+
Type: string
-

Friendly name for this flow (debugging and diagnostics)

-
+ auth_provider
Type: object
+ No Additional Properties -
-
-
-
-
+
-
+

- +

-
+
Type: enum (of string)
-
-

Must be one of:

-
  • "local"
  • "pubsub"
  • "file"
  • "trace"
  • "mqtt"
-
+ auth_provider + + + + basic
Type: object
+ No Additional Properties -
-
-
-
-
+
-
+

- +

-
+
Type: enum (of string)
-
-

Must be one of:

-
  • "ssl"
  • "tcp"
-
+ auth_provider + + + + basic + + + + username
Type: string
+ @@ -9333,18 +9781,18 @@

Must be one of:

-
+
-
+

- +

-
+
Type: string
+ auth_provider + + + + basic + + + + password
Type: string
@@ -9387,18 +9849,22 @@

-
+
+
+
+
+
-
+

- +

-
+
Type: string
-

Simple payload template for simple injection use cases

-
+ auth_provider + + + + jwt
Type: object
+ No Additional Properties -
-
-
-
-
+
-
+

- +

-
+
Type: string
-

Error message container for capturing errors during parsing/handling

-
+ auth_provider + + + + jwt + + + + audience
Type: string
+ @@ -9497,18 +9978,18 @@

-
+
-
+

- +

-
+
Type: integer
- + auth_provider + + + + jwt + + + + token
Type: string
+

Pre-signed JWT token or credential string

+
@@ -9551,18 +10047,22 @@

-
+
+
+
+
+
-
+

- +

-
+
Type: integer
-

Delay waiting for config message on start, 0 for default, <0 to disable

-
+ auth_provider + + + + mtls
Type: object
+ No Additional Properties -
-
-
-
-
+
-
+

- +

-
+
Type: string
- + auth_provider + + + + mtls + + + + private_key
Type: string
+

PEM-encoded private key material for mTLS authentication

+
@@ -9660,18 +10177,18 @@

-
+
-
+

- +

-
+
Type: string
-

Prefix for message topics

-
Must match regular expression: ^[-_/a-zA-Z0-9]+$ + auth_provider + + + + mtls + + + + certificate
Type: string
+

PEM-encoded client certificate for mTLS authentication

+
@@ -9715,18 +10246,18 @@

-
+
-
+

- +

-
+
Type: string
-

Id for the receiving message channel

-
Must match regular expression: ^[-_/a-zA-Z0-9#]+$ + auth_provider + + + + mtls + + + + ca_certificate
Type: string
+

PEM-encoded CA certificate bundle for peer verification

+
@@ -9770,18 +10315,26 @@

-
+
+
+
+
+
+
+
+
+
-
+

- +

-
+
Type: string
-

Id for the sending messages channel

-
Must match regular expression: ^[-_/a-zA-Z0-9#]+$ + ca_file
Type: string
+

Path to the CA certificate file

+
@@ -9825,18 +10378,18 @@

-
+
-
+

- +

-
+
Type: string
-

Id for a side-car message channel

-
Must match regular expression: ^[-_/a-zA-Z0-9#]+$ + cert_file
Type: string
+

Path to the client certificate file

+
@@ -9880,18 +10433,18 @@

-
+
-
+

- +

-
+
Type: string
- + key_file
Type: string
+

Path to the client private key file

+
@@ -9934,18 +10488,18 @@

-
+
-
+

- +

-
+
Type: string
- + generation
Type: stringFormat: date-time
+

The timestamp of the endpoint generation

+
- +
+
Example:
+
"2019-01-17T14:02:29.364Z"
+
+
-
+
-
+

- +

-
+
Type: string
-

Indicator if this endpoint should be active (null or non-empty)

+ expiry
Type: stringFormat: date-time
+

Timestamp when the provisioned credential or endpoint expires

- +
+
Example:
+
"2019-01-17T14:02:29.364Z"
+
+
-
+
+
+
+
+
+
+
+
+
+
+
+
+
-
+

- +

-
+

Iot Access Providers

Type: object
+ No Additional Properties + + + + + + +
+
+
+

+ +

+
+ +
+

+ +

+

All properties whose name matches the following regular expression must respect the following conditions

+ Property name regular expression: ^[a-z]+$ +
+ + Type: boolean
-

True if config messages should not be acked (lower QOS)

-
+ Iot Access

Iot Access

Type: object
+ + + No Additional Properties -
-
-
-
-
+
-
+

- +

-
+
Type: integer
-

Queue capacity for limiting pipes

-
+ name
Type: string
+ @@ -10153,18 +10737,18 @@

-
+
-
+

- +

-
+
Type: integer
-

Artifical publish delay for testing

-
+ provider

Iot Provider

Type: enum (of string)
+ + +
+

Must be one of:

+
  • "local"
  • "dynamic"
  • "implicit"
  • "zanzara"
  • "pubsub"
  • "mqtt"
  • "gbos"
  • "gref"
  • "etcd"
  • "jwt"
  • "clearblade"
+
@@ -10208,18 +10789,18 @@

-
+
-
+

- +

-
+
Type: integer
-

Rate for periodic task execution

-
+ project_id
Type: string
+ @@ -10263,18 +10836,18 @@

-
+
-
+

- +

-
+
Type: object
+ profile_sec
Type: integer
@@ -10317,18 +10883,18 @@

-
+
-
+

- +

-
+
Type: string
+ options
Type: string
@@ -10371,18 +10930,18 @@

-
+
-
+

- +

-
+
Type: object
- No Additional Properties + endpoint

Endpoint Configuration

Type: object
+

Parameters to define a message endpoint

+
+ + No Additional Properties -
+
-
+

- +

-
+
Type: object
- No Additional Properties + name
Type: string
+

Friendly name for this flow (debugging and diagnostics)

+
-
+
+
+
+
+
-
+

- +

-
+
Type: string
- + protocol
Type: enum (of string)
+
+

Must be one of:

+
  • "local"
  • "pubsub"
  • "file"
  • "trace"
  • "mqtt"
+
@@ -10546,18 +11088,18 @@

-
+
-
+

- +

-
+
Type: string
- + transport
Type: enum (of string)
+
+

Must be one of:

+
  • "ssl"
  • "tcp"
+
@@ -10614,22 +11145,18 @@

-
-
-
-
-
+
-
+

- +

-
+
Type: object
- No Additional Properties + hostname
Type: string
+ -
+
+
+
+
+
-
+

- +

-
+
Type: string
- + payload
Type: string
+

Simple payload template for simple injection use cases

+
@@ -10743,26 +11254,18 @@

-
-
-
-
-
-
-
-
-
+
-
+

- +

-
+
Type: string
-

Path to the CA certificate file

+ error
Type: string
+

Error message container for capturing errors during parsing/handling

@@ -10806,18 +11309,1664 @@

-
+
-
+

- +

-
+
+
+ + Type: integer
+ + + + + + + +
+
+
+
+
+
+
+

+ +

+
+ +
+
+ + Type: integer
+

Delay waiting for config message on start, 0 for default, <0 to disable

+
+ + + + + + +
+
+
+
+
+
+
+

+ +

+
+ +
+
+ + Type: string
+ + + + + + + +
+
+
+
+
+
+
+

+ +

+
+ +
+
+ + Type: string
+

Prefix for message topics

+
Must match regular expression: ^[-_/a-zA-Z0-9]+$ + + + + + + +
+
+
+
+
+
+
+

+ +

+
+ +
+
+ + Type: string
+

Id for the receiving message channel

+
Must match regular expression: ^[-_/a-zA-Z0-9#]+$ + + + + + + +
+
+
+
+
+
+
+

+ +

+
+ +
+
+ + Type: string
+

Id for the sending messages channel

+
Must match regular expression: ^[-_/a-zA-Z0-9#]+$ + + + + + + +
+
+
+
+
+
+
+

+ +

+
+ +
+
+ + Type: string
+

Id for a side-car message channel

+
Must match regular expression: ^[-_/a-zA-Z0-9#]+$ + + + + + + +
+
+
+
+
+
+
+

+ +

+
+ +
+
+ + Type: string
+ + + + + + + +
+
+
+
+
+
+
+

+ +

+
+ +
+
+ + Type: string
+ + + + + + + +
+
+
+
+
+
+
+

+ +

+
+ +
+
+ + Type: string
+

Indicator if this endpoint should be active (null or non-empty)

+
+ + + + + + +
+
+
+
+
+
+
+

+ +

+
+ +
+
+ + Type: boolean
+

True if config messages should not be acked (lower QOS)

+
+ + + + + + +
+
+
+
+
+
+
+

+ +

+
+ +
+
+ + Type: integer
+

Queue capacity for limiting pipes

+
+ + + + + + +
+
+
+
+
+
+
+

+ +

+
+ +
+
+ + Type: integer
+

Artifical publish delay for testing

+
+ + + + + + +
+
+
+
+
+
+
+

+ +

+
+ +
+
+ + Type: integer
+

Rate for periodic task execution

+
+ + + + + + +
+
+
+
+
+
+
+

+ +

+
+ +
+
+ + Type: object
+ + + + + + + +
+
+
+
+
+
+
+

+ +

+
+ +
+
+ + Type: string
+ + + + + + + +
+
+
+
+
+
+
+

+ +

+
+ +
+
+ + Type: object
+ No Additional Properties + + + + + + +
+
+
+

+ +

+
+ +
+
+ + Type: object
+ No Additional Properties + + + + + + +
+
+
+

+ +

+
+ +
+
+ + Type: string
+ + + + + + + +
+
+
+
+
+
+
+

+ +

+
+ +
+
+ + Type: string
+ + + + + + + +
+
+
+
+
+
+
+
+
+
+
+

+ +

+
+ +
+
+ + Type: object
+ No Additional Properties + + + + + + +
+
+
+

+ +

+
+ +
+
+ + Type: string
+ + + + + + + +
+
+
+
+
+
+
+

+ +

+
+ +
+
+ + Type: string
+

Pre-signed JWT token or credential string

+
+ + + + + + +
+
+
+
+
+
+
+
+
+
+
+

+ +

+
+ +
+
+ + Type: object
+ No Additional Properties + + + + + + +
+
+
+

+ +

+
+ +
+
+ + Type: string
+

PEM-encoded private key material for mTLS authentication

+
+ + + + + + +
+
+
+
+
+
+
+

+ +

+
+ +
+
+ + Type: string
+

PEM-encoded client certificate for mTLS authentication

+
+ + + + + + +
+
+
+
+
+
+
+

+ +

+
+ +
+
+ + Type: string
+

PEM-encoded CA certificate bundle for peer verification

+
+ + + + + + +
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+

+ +

+
+ +
+
+ + Type: string
+

Path to the CA certificate file

+
+ + + + + + +
+
+
+
+
+
+
+

+ +

+
+ +
-
+
+
+
+

+ +

+
+ +
+
+ + Type: stringFormat: date-time
+

The timestamp of the endpoint generation

+
+ + + + + +
+
Example:
+
"2019-01-17T14:02:29.364Z"
+
+
+
+
+
+
+
-
+

- +

-
+
Type: stringFormat: date-time
-

The timestamp of the endpoint generation

+ expiry
Type: stringFormat: date-time
+

Timestamp when the provisioned credential or endpoint expires

@@ -10968,7 +13176,7 @@


Example:
-
"2019-01-17T14:02:29.364Z"
+
"2019-01-17T14:02:29.364Z"
 
@@ -11160,16 +13368,258 @@

Must be one of:

-
+
+

+ +

+
+ +
+
+ + Type: string
+ + + + + + + +
+
+
+
+
+
+
+

+ +

+
+ +
+
+ + Type: integer
+ + + + + + + +
+
+
+
+
+
+
+

+ +

+
+ +
+
+ + Type: string
+ + + + + + + +
+
+
+
+
+
+
+

+ +

+
+ +
+
+ +

Endpoint Configuration

Type: object
+

Parameters to define a message endpoint

+
+ + No Additional Properties + + + + + + +
+
+
+

+ +

+
+ +
+
+ + Type: string
+

Friendly name for this flow (debugging and diagnostics)

+
+ + + + + + +
+
+
+
+
+
+

- +

-
+
Type: string
- + endpoint + + + + protocol
Type: enum (of string)
+
+

Must be one of:

+
  • "local"
  • "pubsub"
  • "file"
  • "trace"
  • "mqtt"
+
@@ -11205,18 +13665,18 @@

-
+
-
+

- +

-
+
Type: integer
- + endpoint + + + + transport
Type: enum (of string)
+
+

Must be one of:

+
  • "ssl"
  • "tcp"
+
@@ -11252,18 +13722,18 @@

-
+
-
+

- +

-
+
Type: string
+ endpoint + + + + hostname
Type: string
@@ -11299,18 +13776,18 @@

-
+
-
+

- +

-
+

Endpoint Configuration

Type: object
-

Parameters to define a message endpoint

+ endpoint + + + + payload
Type: string
+

Simple payload template for simple injection use cases

- - No Additional Properties -
+
+
+
+
+
-
+

- +

-
+
Type: string
-

Friendly name for this flow (debugging and diagnostics)

+ error
Type: string
+

Error message container for capturing errors during parsing/handling

@@ -11400,18 +13886,18 @@

-
+
-
+

- +

-
+
Type: enum (of string)
-
-

Must be one of:

-
  • "local"
  • "pubsub"
  • "file"
  • "trace"
  • "mqtt"
-
+ port
Type: integer
+ @@ -11457,18 +13940,18 @@

Must be one of:

-
+
-
+

- +

-
+
Type: enum (of string)
-
-

Must be one of:

-
  • "ssl"
  • "tcp"
-
+ config_sync_sec
Type: integer
+

Delay waiting for config message on start, 0 for default, <0 to disable

+
@@ -11514,18 +13995,18 @@

Must be one of:

-
+
-
+

- +

-
+
Type: string
+ client_id
Type: string
@@ -11568,18 +14049,18 @@

-
+
-
+

- +

-
+
Type: string
-

Simple payload template for simple injection use cases

-
+ topic_prefix
Type: string
+

Prefix for message topics

+
Must match regular expression: ^[-_/a-zA-Z0-9]+$ @@ -11623,18 +14104,18 @@

-
+
-
+

- +

-
+
Type: string
-

Error message container for capturing errors during parsing/handling

-
+ recv_id
Type: string
+

Id for the receiving message channel

+
Must match regular expression: ^[-_/a-zA-Z0-9#]+$ @@ -11678,18 +14159,18 @@

-
+
-
+

- +

-
+
Type: integer
- + send_id
Type: string
+

Id for the sending messages channel

+
Must match regular expression: ^[-_/a-zA-Z0-9#]+$ @@ -11732,18 +14214,18 @@

-
+
-
+

- +

-
+
Type: integer
-

Delay waiting for config message on start, 0 for default, <0 to disable

-
+ side_id
Type: string
+

Id for a side-car message channel

+
Must match regular expression: ^[-_/a-zA-Z0-9#]+$ @@ -11787,18 +14269,18 @@

-
+
-
+

- +

-
+
Type: string
+ gatewayId
Type: string
@@ -11841,18 +14323,18 @@

-
+
-
+

- +

-
+
Type: string
-

Prefix for message topics

-
Must match regular expression: ^[-_/a-zA-Z0-9]+$ + deviceId
Type: string
+ @@ -11896,18 +14377,18 @@

-
+
-
+

- +

-
+
Type: string
-

Id for the receiving message channel

-
Must match regular expression: ^[-_/a-zA-Z0-9#]+$ + enabled
Type: string
+

Indicator if this endpoint should be active (null or non-empty)

+
@@ -11951,18 +14432,18 @@

-
+
-
+

- +

-
+
Type: string
-

Id for the sending messages channel

-
Must match regular expression: ^[-_/a-zA-Z0-9#]+$ + noConfigAck
Type: boolean
+

True if config messages should not be acked (lower QOS)

+
@@ -12006,18 +14487,18 @@

-
+
-
+

- +

-
+
Type: string
-

Id for a side-car message channel

-
Must match regular expression: ^[-_/a-zA-Z0-9#]+$ + capacity
Type: integer
+

Queue capacity for limiting pipes

+
@@ -12061,18 +14542,18 @@

-
+
-
+

- +

-
+
Type: string
- + publish_delay_sec
Type: integer
+

Artifical publish delay for testing

+
@@ -12115,18 +14597,18 @@

-
+
-
+

- +

-
+
Type: string
- + periodic_sec
Type: integer
+

Rate for periodic task execution

+
@@ -12169,18 +14652,18 @@

-
+
-
+

- +

-
+
Type: string
-

Indicator if this endpoint should be active (null or non-empty)

-
+ keyBytes
Type: object
+ @@ -12224,18 +14706,18 @@

-
+
-
+

- +

-
+
Type: boolean
-

True if config messages should not be acked (lower QOS)

-
+ algorithm
Type: string
+ @@ -12279,18 +14760,18 @@

-
+
-
+

- +

-
+
Type: integer
-

Queue capacity for limiting pipes

-
+ auth_provider
Type: object
+ No Additional Properties -
-
-
-
-
+
-
+

- +

-
+
Type: integer
-

Artifical publish delay for testing

-
+ auth_provider + + + + basic
Type: object
+ No Additional Properties -
-
-
-
-
+
-
+

- +

-
+
Type: integer
-

Rate for periodic task execution

-
+ username
Type: string
+ @@ -12444,18 +14935,18 @@

-
+
-
+

- +

-
+
Type: object
+ auth_provider + + + + basic + + + + password
Type: string
@@ -12498,18 +15003,22 @@

-
+
+
+
+
+
-
+

- +

-
+
Type: string
- + auth_provider + + + + jwt
Type: object
+ No Additional Properties -
-
-
-
-
+
-
+

- +

-
+
Type: object
- No Additional Properties + auth_provider + + + + jwt + + + + audience
Type: string
+ -
+
+
+
+
+
-
+

- +

-
+
Type: object
- No Additional Properties + jwt + + + + token
Type: string
+

Pre-signed JWT token or credential string

+
-
+
+
+
+
+
+
+
+
+
-
+

- +

-
+
Type: string
- + mtls
Type: object
+ No Additional Properties -
-
-
-
-
+
-
+

- +

-
+
Type: string
- + private_key
Type: string
+

PEM-encoded private key material for mTLS authentication

+
@@ -12795,22 +15331,18 @@

-
-
-
-
-
+
-
+

- +

-
+
Type: object
- No Additional Properties + mtls + + + + certificate
Type: string
+

PEM-encoded client certificate for mTLS authentication

+
-
+
+
+
+
+
-
+

- +

-
+
Type: string
- + ca_certificate
Type: string
+

PEM-encoded CA certificate bundle for peer verification

+
@@ -13151,6 +15696,65 @@

Example:

"2019-01-17T14:02:29.364Z"
 
+
+

+
+
+
+
+
+
+

+ +

+
+ +
+
+ + Type: stringFormat: date-time
+

Timestamp when the provisioned credential or endpoint expires

+
+ + + + + +
+
Example:
+
"2019-01-17T14:02:29.364Z"
+
diff --git a/gencode/docs/configuration_pubber.html b/gencode/docs/configuration_pubber.html index 1a0313579f..6326999602 100644 --- a/gencode/docs/configuration_pubber.html +++ b/gencode/docs/configuration_pubber.html @@ -1244,6 +1244,273 @@

+

+
+
+
+
+
+
+

+ +

+
+ +
+
+ + Type: string
+

Pre-signed JWT token or credential string

+
+ + + + + + +
+
+
+
+
+
+
+
+
+
+
+

+ +

+
+ +
+
+ + Type: object
+ No Additional Properties + + + + + + +
+
+
+

+ +

+
+ +
+
+ + Type: string
+

PEM-encoded private key material for mTLS authentication

+
+ + + + + + +
+
+
+
+
+
+
+

+ +

+
+ +
+
+ + Type: string
+

PEM-encoded client certificate for mTLS authentication

+
+ + + + + + +
+
+
+
+
+
+
+

+ +

+
+ +
+
+ + Type: string
+

PEM-encoded CA certificate bundle for peer verification

+
+ + + + + +
@@ -1419,6 +1686,51 @@

Example:

"2019-01-17T14:02:29.364Z"
 
+
+

+
+
+
+
+
+
+

+ +

+
+ +
+
+ + Type: stringFormat: date-time
+

Timestamp when the provisioned credential or endpoint expires

+
+ + + + + +
+
Example:
+
"2019-01-17T14:02:29.364Z"
+
diff --git a/gencode/docs/download_blobset.html b/gencode/docs/download_blobset.html new file mode 100644 index 0000000000..bea1660678 --- /dev/null +++ b/gencode/docs/download_blobset.html @@ -0,0 +1,2131 @@ + + + + + + + + + + + + + + + + Blobset Download + +
+ + +
+ +

Blobset Download

Type: object
+

Direct cloud-to-device payload delivery for blobset configurations over the download/blobset channel.

+
No Additional Properties + + + + + + +
+
+
+

+ +

+
+ +
+
+ + Type: stringFormat: date-time
+

RFC 3339 UTC timestamp the download payload was generated

+
+ + + + + +
+
Example:
+
"2019-01-17T14:02:29.364Z"
+
+
+
+
+
+
+
+
+
+

+ +

+
+ +
+
+ + Type: string
+

Version of the UDMI schema

+
+ + + + + + +
+
+
+
+
+
+
+

+ +

+
+ +
+
+ + Type: object
+

Map of system blobset keys to their provisioned endpoint configuration payloads

+
No Additional Properties + + + + + + +
+
+
+

+ +

+
+ +
+

+ +

+

All properties whose name matches the following regular expression must respect the following conditions

+ Property name regular expression: ^_[a-z_]+$ +
+ +

Endpoint Configuration

Type: object
+

Parameters to define a message endpoint

+
+ + No Additional Properties + + + + + + +
+
+
+

+ +

+
+ +
+
+ + Type: string
+

Friendly name for this flow (debugging and diagnostics)

+
+ + + + + + +
+
+
+
+
+
+
+

+ +

+
+ +
+
+ + Type: enum (of string)
+
+

Must be one of:

+
  • "local"
  • "pubsub"
  • "file"
  • "trace"
  • "mqtt"
+
+ + + + + + +
+
+
+
+
+
+
+

+ +

+
+ +
+
+ + Type: enum (of string)
+
+

Must be one of:

+
  • "ssl"
  • "tcp"
+
+ + + + + + +
+
+
+
+
+
+
+

+ +

+
+ +
+
+ + Type: string
+ + + + + + + +
+
+
+
+
+
+
+

+ +

+
+ +
+
+ + Type: string
+

Simple payload template for simple injection use cases

+
+ + + + + + +
+
+
+
+
+
+
+

+ +

+
+ +
+
+ + Type: string
+

Error message container for capturing errors during parsing/handling

+
+ + + + + + +
+
+
+
+
+
+
+

+ +

+
+ +
+
+ + Type: integer
+ + + + + + + +
+
+
+
+
+
+
+

+ +

+
+ +
+
+ + Type: integer
+

Delay waiting for config message on start, 0 for default, <0 to disable

+
+ + + + + + +
+
+
+
+
+
+
+

+ +

+
+ +
+
+ + Type: string
+ + + + + + + +
+
+
+
+
+
+
+

+ +

+
+ +
+
+ + Type: string
+

Prefix for message topics

+
Must match regular expression: ^[-_/a-zA-Z0-9]+$ + + + + + + +
+
+
+
+
+
+
+

+ +

+
+ +
+
+ + Type: string
+

Id for the receiving message channel

+
Must match regular expression: ^[-_/a-zA-Z0-9#]+$ + + + + + + +
+
+
+
+
+
+
+

+ +

+
+ +
+
+ + Type: string
+

Id for the sending messages channel

+
Must match regular expression: ^[-_/a-zA-Z0-9#]+$ + + + + + + +
+
+
+
+
+
+
+

+ +

+
+ +
+
+ + Type: string
+

Id for a side-car message channel

+
Must match regular expression: ^[-_/a-zA-Z0-9#]+$ + + + + + + +
+
+
+
+
+
+
+

+ +

+
+ +
+
+ + Type: string
+ + + + + + + +
+
+
+
+
+
+
+

+ +

+
+ +
+
+ + Type: string
+ + + + + + + +
+
+
+
+
+
+
+

+ +

+
+ +
+
+ + Type: string
+

Indicator if this endpoint should be active (null or non-empty)

+
+ + + + + + +
+
+
+
+
+
+
+

+ +

+
+ +
+
+ + Type: boolean
+

True if config messages should not be acked (lower QOS)

+
+ + + + + + +
+
+
+
+
+
+
+

+ +

+
+ +
+
+ + Type: integer
+

Queue capacity for limiting pipes

+
+ + + + + + +
+
+
+
+
+
+
+

+ +

+
+ +
+
+ + Type: integer
+

Artifical publish delay for testing

+
+ + + + + + +
+
+
+
+
+
+
+

+ +

+
+ +
+
+ + Type: integer
+

Rate for periodic task execution

+
+ + + + + + +
+
+
+
+
+
+
+

+ +

+
+ +
+
+ + Type: object
+ + + + + + + +
+
+
+
+
+
+
+

+ +

+
+ +
+
+ + Type: string
+ + + + + + + +
+
+
+
+
+
+
+

+ +

+
+ +
+
+ + Type: object
+ No Additional Properties + + + + + + +
+
+
+

+ +

+
+ +
+
+ + Type: object
+ No Additional Properties + + + + + + +
+
+
+

+ +

+
+ +
+
+ + Type: string
+ + + + + + + +
+
+
+
+
+
+
+

+ +

+
+ +
+
+ + Type: string
+ + + + + + + +
+
+
+
+
+
+
+
+
+
+
+

+ +

+
+ +
+
+ + Type: object
+ No Additional Properties + + + + + + +
+
+
+

+ +

+
+ +
+
+ + Type: string
+ + + + + + + +
+
+
+
+
+
+
+

+ +

+
+ +
+
+ + Type: string
+

Pre-signed JWT token or credential string

+
+ + + + + + +
+
+
+
+
+
+
+
+
+
+
+

+ +

+
+ +
+
+ + Type: object
+ No Additional Properties + + + + + + +
+
+
+

+ +

+
+ +
+
+ + Type: string
+

PEM-encoded private key material for mTLS authentication

+
+ + + + + + +
+
+
+
+
+
+
+

+ +

+
+ +
+
+ + Type: string
+

PEM-encoded client certificate for mTLS authentication

+
+ + + + + + +
+
+
+
+
+
+
+

+ +

+
+ +
+
+ + Type: string
+

PEM-encoded CA certificate bundle for peer verification

+
+ + + + + + +
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+

+ +

+
+ +
+
+ + Type: string
+

Path to the CA certificate file

+
+ + + + + + +
+
+
+
+
+
+
+

+ +

+
+ +
+
+ + Type: string
+

Path to the client certificate file

+
+ + + + + + +
+
+
+
+
+
+
+

+ +

+
+ +
+
+ + Type: string
+

Path to the client private key file

+
+ + + + + + +
+
+
+
+
+
+
+

+ +

+
+ +
+
+ + Type: stringFormat: date-time
+

The timestamp of the endpoint generation

+
+ + + + + +
+
Example:
+
"2019-01-17T14:02:29.364Z"
+
+
+
+
+
+
+
+
+
+

+ +

+
+ +
+
+ + Type: stringFormat: date-time
+

Timestamp when the provisioned credential or endpoint expires

+
+ + + + + +
+
Example:
+
"2019-01-17T14:02:29.364Z"
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ + + \ No newline at end of file diff --git a/gencode/docs/events.html b/gencode/docs/events.html index 863b9e74b5..d96f5b78ca 100644 --- a/gencode/docs/events.html +++ b/gencode/docs/events.html @@ -25547,329 +25547,6 @@

-
-
-
-
-
-
-
-
-
-
-
-

- -

-
- -
-
- -

Streams Events

Type: object
-

Sequential data stream chunks for reliable transport over MQTT (e.g. PCAP traces, firmware blobs, reliable alarms, logs playback).

-
- - No Additional Properties - - - - - - -
-
-
-

- -

-
- -
-
- - Type: stringFormat: date-time
-

RFC 3339 UTC timestamp the stream chunk event was generated

-
- - - - - - -
-
-
-
-
-
-
-

- -

-
- -
-
- - Type: string
-

Version of the UDMI schema

-
- - - - - - -
-
-
-
-
-
-
-

- -

-
- -
-
- - Type: string
-

Unique session identifier for the continuous stream transmission

-
- - - - - - -
-
-
-
-
-
-
-

- -

-
- -
-
- - Type: integer
-

Sequence number for this stream event to support reliable sequential delivery and reassembly

-
- - - - - - -
-
-
-
-
-
-
-

- -

-
- -
-
- - Type: integer
-

0-based index of the transmitted chunk payload within the total session

-
- - - - - - -
-
-
-
-
-
-
-

- -

-
- -
-
- - Type: integer
-

Total number of chunks comprising the complete transmission binary

-
- - - - - - -
-
-
-
-
-
-
-

- -

-
- -
-
- - Type: string
-

Base64-encoded binary chunk payload data

-
- - - - - -
diff --git a/gencode/docs/monitoring.html b/gencode/docs/monitoring.html index 9bcd1299d8..e399bfba0e 100644 --- a/gencode/docs/monitoring.html +++ b/gencode/docs/monitoring.html @@ -4547,7 +4547,7 @@

subFolder

Type: enum (of string)

Must be one of:

-
  • "udmi"
  • "update"
  • "invalid"
  • "error"
  • "cloud"
  • "discovery"
  • "mapping"
  • "system"
  • "gateway"
  • "swarm"
  • "localnet"
  • "pointset"
  • "validation"
  • "blobset"
  • "monitoring"
  • "streams"
+
  • "udmi"
  • "update"
  • "invalid"
  • "error"
  • "cloud"
  • "discovery"
  • "mapping"
  • "system"
  • "gateway"
  • "swarm"
  • "localnet"
  • "pointset"
  • "validation"
  • "blobset"
  • "monitoring"
@@ -4597,7 +4597,7 @@

subType

Type: enum (of string)

Must be one of:

-
  • "invalid"
  • "local"
  • "reflect"
  • "events"
  • "errors"
  • "commands"
  • "config"
  • "state"
  • "query"
  • "reply"
  • "model"
  • "propose"
+
  • "invalid"
  • "local"
  • "reflect"
  • "events"
  • "errors"
  • "commands"
  • "config"
  • "state"
  • "query"
  • "reply"
  • "model"
  • "propose"
  • "upload"
  • "download"
diff --git a/gencode/docs/persistent_device.html b/gencode/docs/persistent_device.html index c627a9c45f..57f569ca52 100644 --- a/gencode/docs/persistent_device.html +++ b/gencode/docs/persistent_device.html @@ -1244,6 +1244,273 @@

+

+
+
+
+
+
+
+

+ +

+
+ +
+
+ + Type: string
+

Pre-signed JWT token or credential string

+
+ + + + + + +
+
+
+
+
+
+
+
+
+
+
+

+ +

+
+ +
+
+ + Type: object
+ No Additional Properties + + + + + + +
+
+
+

+ +

+
+ +
+
+ + Type: string
+

PEM-encoded private key material for mTLS authentication

+
+ + + + + + +
+
+
+
+
+
+
+

+ +

+
+ +
+
+ + Type: string
+

PEM-encoded client certificate for mTLS authentication

+
+ + + + + + +
+
+
+
+
+
+
+

+ +

+
+ +
+
+ + Type: string
+

PEM-encoded CA certificate bundle for peer verification

+
+ + + + + +
@@ -1419,6 +1686,51 @@

Example:

"2019-01-17T14:02:29.364Z"
 
+
+

+
+
+
+
+
+
+

+ +

+
+ +
+
+ + Type: stringFormat: date-time
+

Timestamp when the provisioned credential or endpoint expires

+
+ + + + + +
+
Example:
+
"2019-01-17T14:02:29.364Z"
+
diff --git a/gencode/docs/readme.md b/gencode/docs/readme.md index a4843f5eab..43db41ad43 100644 --- a/gencode/docs/readme.md +++ b/gencode/docs/readme.md @@ -7,13 +7,14 @@ ## Messages * [**config**](config.html) - The config block controls a device's intended behavior. [Config Documentation](../../docs/messages/config.md) +* [**download_blobset**](download_blobset.html) - Direct cloud-to-device payload delivery for blobset configurations over the download/blobset channel. * [**events_alarmset**](events_alarmset.html) - A set of alarms reporting telemetry data. [Alarmset Events Documentation](../../docs/messages/alarmset.md#telemetry) * [**events_discovery**](events_discovery.html) - [Discovery result](../../docs/specs/discovery.md) with implicit discovery * [**events_pointset**](events_pointset.html) - A set of points reporting telemetry data. [Pointset Events Documentation](../../docs/messages/pointset.md#telemetry) -* [**events_streams**](events_streams.html) - Sequential data stream chunks for reliable transport over MQTT (e.g. PCAP traces, firmware blobs, reliable alarms, logs playback). * [**events_system**](events_system.html) - Used for system events such as logging. [System Event Documentation](../../docs/messages/system.md#event) * [**events_udmi**](events_udmi.html) - Used for udmi events such as logging. * [**state**](state.html) - [State](../../docs/messages/state.md) message, defined by [`state.json`] +* [**upload_discovery**](upload_discovery.html) - Sequential binary chunk upload for discovery artifacts (e.g. PCAP traces) framed by config.discovery and state.discovery. ## Site Model * [**metadata**](metadata.html) - [Metadata](../../docs/specs/metadata.md) is a description about the device: a specification about how the device should be configured and expectations about what the device should be doing. Defined by `metadata.json` diff --git a/gencode/docs/events_streams.html b/gencode/docs/upload_discovery.html similarity index 66% rename from gencode/docs/events_streams.html rename to gencode/docs/upload_discovery.html index 75f0bea9e5..958901c66d 100644 --- a/gencode/docs/events_streams.html +++ b/gencode/docs/upload_discovery.html @@ -13,15 +13,15 @@ - Streams Events + Discovery Upload
-

Streams Events

Type: object
-

Sequential data stream chunks for reliable transport over MQTT (e.g. PCAP traces, firmware blobs, reliable alarms, logs playback).

+

Discovery Upload

Type: object
+

Sequential binary chunk upload for discovery artifacts (e.g. PCAP traces) framed by config.discovery and state.discovery.

No Additional Properties @@ -34,7 +34,7 @@

+ aria-expanded="" aria-controls="timestamp" onclick="setAnchor('#timestamp')">timestamp Required

@@ -51,14 +51,18 @@

/> timestamp

Type: stringFormat: date-time
-

RFC 3339 UTC timestamp the stream chunk event was generated

+

RFC 3339 UTC timestamp the upload chunk was generated

- +
+
Example:
+
"2019-01-17T14:02:29.364Z"
+
+
@@ -68,7 +72,7 @@

+ aria-expanded="" aria-controls="version" onclick="setAnchor('#version')">version Required

@@ -93,6 +97,78 @@

+

+
+
+
+
+
+
+

+ +

+
+ +
+
+ + Type: string
+

Protocol family associated with the discovery trace upload

+
+ + + + + + +
+
+
+
+
+
+
+

+ +

+
+ +
+
+ + Type: stringFormat: date-time
+

The discovery scan trigger's generation timestamp

+
+ + + + + +
+
Example:
+
"2019-01-17T14:02:29.364Z"
+
+
@@ -102,7 +178,7 @@

+ aria-expanded="" aria-controls="session_id" onclick="setAnchor('#session_id')">session_id Required

@@ -119,7 +195,7 @@

/> session_id

Type: string
-

Unique session identifier for the continuous stream transmission

+

Unique session identifier for the chunked upload transmission

@@ -136,7 +212,7 @@

+ aria-expanded="" aria-controls="event_no" onclick="setAnchor('#event_no')">event_no Required

@@ -153,7 +229,7 @@

/> event_no

Type: integer
-

Sequence number for this stream event to support reliable sequential delivery and reassembly

+

Sequence number for this upload chunk: 0 for start marker, 1..N for data chunks, -(N + 1) for terminal EOF marker

@@ -229,6 +305,40 @@

+

+
+
+
+
+
+
+

+ +

+
+ +
+
+ + Type: string
+

SHA-256 hex digest of the complete unencoded binary payload (included on the terminal negative event_no marker)

+
Must match regular expression: ^[0-9a-fA-F]{64}$ + + + + + +
diff --git a/gencode/java/udmi/schema/Auth_provider.java b/gencode/java/udmi/schema/Auth_provider.java index 562bcebd70..e32dbb3572 100644 --- a/gencode/java/udmi/schema/Auth_provider.java +++ b/gencode/java/udmi/schema/Auth_provider.java @@ -12,11 +12,14 @@ public class Auth_provider { public Basic basic; @JsonProperty("jwt") public Jwt jwt; + @JsonProperty("mtls") + public Mtls mtls; @Override public int hashCode() { int result = 1; result = ((result* 31)+((this.basic == null)? 0 :this.basic.hashCode())); + result = ((result* 31)+((this.mtls == null)? 0 :this.mtls.hashCode())); result = ((result* 31)+((this.jwt == null)? 0 :this.jwt.hashCode())); return result; } @@ -30,7 +33,7 @@ public boolean equals(Object other) { return false; } Auth_provider rhs = ((Auth_provider) other); - return (((this.basic == rhs.basic)||((this.basic!= null)&&this.basic.equals(rhs.basic)))&&((this.jwt == rhs.jwt)||((this.jwt!= null)&&this.jwt.equals(rhs.jwt)))); + return ((((this.basic == rhs.basic)||((this.basic!= null)&&this.basic.equals(rhs.basic)))&&((this.mtls == rhs.mtls)||((this.mtls!= null)&&this.mtls.equals(rhs.mtls))))&&((this.jwt == rhs.jwt)||((this.jwt!= null)&&this.jwt.equals(rhs.jwt)))); } } diff --git a/gencode/java/udmi/schema/BlobsetConfig.java b/gencode/java/udmi/schema/BlobsetConfig.java index 7fd731c1a5..1d0f8aa387 100644 --- a/gencode/java/udmi/schema/BlobsetConfig.java +++ b/gencode/java/udmi/schema/BlobsetConfig.java @@ -61,7 +61,9 @@ public boolean equals(Object other) { */ public enum SystemBlobsets { - IOT_ENDPOINT_CONFIG("_iot_endpoint_config"); + IOT_ENDPOINT_CONFIG("_iot_endpoint_config"), + BACNET_SC_CONFIG("_bacnet_sc_config"), + DISCOVERY_AUTH("_discovery_auth"); private final java.lang.String value; private final static Map CONSTANTS = new HashMap(); diff --git a/gencode/java/udmi/schema/BlobsetDownload.java b/gencode/java/udmi/schema/BlobsetDownload.java new file mode 100644 index 0000000000..f69fd99289 --- /dev/null +++ b/gencode/java/udmi/schema/BlobsetDownload.java @@ -0,0 +1,67 @@ + +package udmi.schema; + +import java.util.Date; +import java.util.HashMap; +import com.fasterxml.jackson.annotation.JsonInclude; +import com.fasterxml.jackson.annotation.JsonProperty; +import com.fasterxml.jackson.annotation.JsonPropertyDescription; +import com.fasterxml.jackson.annotation.JsonPropertyOrder; + + +/** + * Blobset Download + *

+ * Direct cloud-to-device payload delivery for blobset configurations over the download/blobset channel. + * + */ +@JsonInclude(JsonInclude.Include.NON_NULL) +public class BlobsetDownload { + + /** + * RFC 3339 UTC timestamp the download payload was generated + * (Required) + * + */ + @JsonProperty("timestamp") + @JsonPropertyDescription("RFC 3339 UTC timestamp the download payload was generated") + public Date timestamp; + /** + * Version of the UDMI schema + * (Required) + * + */ + @JsonProperty("version") + @JsonPropertyDescription("Version of the UDMI schema") + public java.lang.String version; + /** + * Map of system blobset keys to their provisioned endpoint configuration payloads + * (Required) + * + */ + @JsonProperty("blobs") + @JsonPropertyDescription("Map of system blobset keys to their provisioned endpoint configuration payloads") + public HashMap blobs; + + @Override + public int hashCode() { + int result = 1; + result = ((result* 31)+((this.version == null)? 0 :this.version.hashCode())); + result = ((result* 31)+((this.blobs == null)? 0 :this.blobs.hashCode())); + result = ((result* 31)+((this.timestamp == null)? 0 :this.timestamp.hashCode())); + return result; + } + + @Override + public boolean equals(Object other) { + if (other == this) { + return true; + } + if ((other instanceof BlobsetDownload) == false) { + return false; + } + BlobsetDownload rhs = ((BlobsetDownload) other); + return ((((this.version == rhs.version)||((this.version!= null)&&this.version.equals(rhs.version)))&&((this.blobs == rhs.blobs)||((this.blobs!= null)&&this.blobs.equals(rhs.blobs))))&&((this.timestamp == rhs.timestamp)||((this.timestamp!= null)&&this.timestamp.equals(rhs.timestamp)))); + } + +} diff --git a/gencode/java/udmi/schema/DiscoveryUpload.java b/gencode/java/udmi/schema/DiscoveryUpload.java new file mode 100644 index 0000000000..354489f358 --- /dev/null +++ b/gencode/java/udmi/schema/DiscoveryUpload.java @@ -0,0 +1,123 @@ + +package udmi.schema; + +import java.util.Date; +import com.fasterxml.jackson.annotation.JsonInclude; +import com.fasterxml.jackson.annotation.JsonProperty; +import com.fasterxml.jackson.annotation.JsonPropertyDescription; +import com.fasterxml.jackson.annotation.JsonPropertyOrder; + + +/** + * Discovery Upload + *

+ * Sequential binary chunk upload for discovery artifacts (e.g. PCAP traces) framed by config.discovery and state.discovery. + * + */ +@JsonInclude(JsonInclude.Include.NON_NULL) +public class DiscoveryUpload { + + /** + * RFC 3339 UTC timestamp the upload chunk was generated + * (Required) + * + */ + @JsonProperty("timestamp") + @JsonPropertyDescription("RFC 3339 UTC timestamp the upload chunk was generated") + public Date timestamp; + /** + * Version of the UDMI schema + * (Required) + * + */ + @JsonProperty("version") + @JsonPropertyDescription("Version of the UDMI schema") + public String version; + /** + * Protocol family associated with the discovery trace upload + * + */ + @JsonProperty("family") + @JsonPropertyDescription("Protocol family associated with the discovery trace upload") + public String family; + /** + * The discovery scan trigger's generation timestamp + * + */ + @JsonProperty("generation") + @JsonPropertyDescription("The discovery scan trigger's generation timestamp") + public Date generation; + /** + * Unique session identifier for the chunked upload transmission + * (Required) + * + */ + @JsonProperty("session_id") + @JsonPropertyDescription("Unique session identifier for the chunked upload transmission") + public String session_id; + /** + * Sequence number for this upload chunk: 0 for start marker, 1..N for data chunks, -(N + 1) for terminal EOF marker + * (Required) + * + */ + @JsonProperty("event_no") + @JsonPropertyDescription("Sequence number for this upload chunk: 0 for start marker, 1..N for data chunks, -(N + 1) for terminal EOF marker") + public Integer event_no; + /** + * 0-based index of the transmitted chunk payload within the total session + * + */ + @JsonProperty("chunk_index") + @JsonPropertyDescription("0-based index of the transmitted chunk payload within the total session") + public Integer chunk_index; + /** + * Total number of chunks comprising the complete transmission binary + * + */ + @JsonProperty("total_chunks") + @JsonPropertyDescription("Total number of chunks comprising the complete transmission binary") + public Integer total_chunks; + /** + * SHA-256 hex digest of the complete unencoded binary payload (included on the terminal negative event_no marker) + * + */ + @JsonProperty("sha256") + @JsonPropertyDescription("SHA-256 hex digest of the complete unencoded binary payload (included on the terminal negative event_no marker)") + public String sha256; + /** + * Base64-encoded binary chunk payload data + * + */ + @JsonProperty("data") + @JsonPropertyDescription("Base64-encoded binary chunk payload data") + public String data; + + @Override + public int hashCode() { + int result = 1; + result = ((result* 31)+((this.generation == null)? 0 :this.generation.hashCode())); + result = ((result* 31)+((this.chunk_index == null)? 0 :this.chunk_index.hashCode())); + result = ((result* 31)+((this.sha256 == null)? 0 :this.sha256 .hashCode())); + result = ((result* 31)+((this.data == null)? 0 :this.data.hashCode())); + result = ((result* 31)+((this.event_no == null)? 0 :this.event_no.hashCode())); + result = ((result* 31)+((this.session_id == null)? 0 :this.session_id.hashCode())); + result = ((result* 31)+((this.family == null)? 0 :this.family.hashCode())); + result = ((result* 31)+((this.version == null)? 0 :this.version.hashCode())); + result = ((result* 31)+((this.total_chunks == null)? 0 :this.total_chunks.hashCode())); + result = ((result* 31)+((this.timestamp == null)? 0 :this.timestamp.hashCode())); + return result; + } + + @Override + public boolean equals(Object other) { + if (other == this) { + return true; + } + if ((other instanceof DiscoveryUpload) == false) { + return false; + } + DiscoveryUpload rhs = ((DiscoveryUpload) other); + return (((((((((((this.generation == rhs.generation)||((this.generation!= null)&&this.generation.equals(rhs.generation)))&&((this.chunk_index == rhs.chunk_index)||((this.chunk_index!= null)&&this.chunk_index.equals(rhs.chunk_index))))&&((this.sha256 == rhs.sha256)||((this.sha256 != null)&&this.sha256 .equals(rhs.sha256))))&&((this.data == rhs.data)||((this.data!= null)&&this.data.equals(rhs.data))))&&((this.event_no == rhs.event_no)||((this.event_no!= null)&&this.event_no.equals(rhs.event_no))))&&((this.session_id == rhs.session_id)||((this.session_id!= null)&&this.session_id.equals(rhs.session_id))))&&((this.family == rhs.family)||((this.family!= null)&&this.family.equals(rhs.family))))&&((this.version == rhs.version)||((this.version!= null)&&this.version.equals(rhs.version))))&&((this.total_chunks == rhs.total_chunks)||((this.total_chunks!= null)&&this.total_chunks.equals(rhs.total_chunks))))&&((this.timestamp == rhs.timestamp)||((this.timestamp!= null)&&this.timestamp.equals(rhs.timestamp)))); + } + +} diff --git a/gencode/java/udmi/schema/EndpointConfiguration.java b/gencode/java/udmi/schema/EndpointConfiguration.java index 81ab05cee6..ec6d589e07 100644 --- a/gencode/java/udmi/schema/EndpointConfiguration.java +++ b/gencode/java/udmi/schema/EndpointConfiguration.java @@ -175,6 +175,13 @@ public class EndpointConfiguration { @JsonProperty("generation") @JsonPropertyDescription("The timestamp of the endpoint generation") public Date generation; + /** + * Timestamp when the provisioned credential or endpoint expires + * + */ + @JsonProperty("expiry") + @JsonPropertyDescription("Timestamp when the provisioned credential or endpoint expires") + public Date expiry; @Override public int hashCode() { @@ -193,6 +200,7 @@ public int hashCode() { result = ((result* 31)+((this.hostname == null)? 0 :this.hostname.hashCode())); result = ((result* 31)+((this.payload == null)? 0 :this.payload.hashCode())); result = ((result* 31)+((this.topic_prefix == null)? 0 :this.topic_prefix.hashCode())); + result = ((result* 31)+((this.expiry == null)? 0 :this.expiry.hashCode())); result = ((result* 31)+((this.recv_id == null)? 0 :this.recv_id.hashCode())); result = ((result* 31)+((this.algorithm == null)? 0 :this.algorithm.hashCode())); result = ((result* 31)+((this.generation == null)? 0 :this.generation.hashCode())); @@ -218,7 +226,7 @@ public boolean equals(Object other) { return false; } EndpointConfiguration rhs = ((EndpointConfiguration) other); - return ((((((((((((((((((((((((((((this.keyBytes == rhs.keyBytes)||((this.keyBytes!= null)&&this.keyBytes.equals(rhs.keyBytes)))&&((this.ca_file == rhs.ca_file)||((this.ca_file!= null)&&this.ca_file.equals(rhs.ca_file))))&&((this.key_file == rhs.key_file)||((this.key_file!= null)&&this.key_file.equals(rhs.key_file))))&&((this.cert_file == rhs.cert_file)||((this.cert_file!= null)&&this.cert_file.equals(rhs.cert_file))))&&((this.side_id == rhs.side_id)||((this.side_id!= null)&&this.side_id.equals(rhs.side_id))))&&((this.error == rhs.error)||((this.error!= null)&&this.error.equals(rhs.error))))&&((this.deviceId == rhs.deviceId)||((this.deviceId!= null)&&this.deviceId.equals(rhs.deviceId))))&&((this.client_id == rhs.client_id)||((this.client_id!= null)&&this.client_id.equals(rhs.client_id))))&&((this.enabled == rhs.enabled)||((this.enabled!= null)&&this.enabled.equals(rhs.enabled))))&&((this.capacity == rhs.capacity)||((this.capacity!= null)&&this.capacity.equals(rhs.capacity))))&&((this.protocol == rhs.protocol)||((this.protocol!= null)&&this.protocol.equals(rhs.protocol))))&&((this.hostname == rhs.hostname)||((this.hostname!= null)&&this.hostname.equals(rhs.hostname))))&&((this.payload == rhs.payload)||((this.payload!= null)&&this.payload.equals(rhs.payload))))&&((this.topic_prefix == rhs.topic_prefix)||((this.topic_prefix!= null)&&this.topic_prefix.equals(rhs.topic_prefix))))&&((this.recv_id == rhs.recv_id)||((this.recv_id!= null)&&this.recv_id.equals(rhs.recv_id))))&&((this.algorithm == rhs.algorithm)||((this.algorithm!= null)&&this.algorithm.equals(rhs.algorithm))))&&((this.generation == rhs.generation)||((this.generation!= null)&&this.generation.equals(rhs.generation))))&&((this.transport == rhs.transport)||((this.transport!= null)&&this.transport.equals(rhs.transport))))&&((this.publish_delay_sec == rhs.publish_delay_sec)||((this.publish_delay_sec!= null)&&this.publish_delay_sec.equals(rhs.publish_delay_sec))))&&((this.config_sync_sec == rhs.config_sync_sec)||((this.config_sync_sec!= null)&&this.config_sync_sec.equals(rhs.config_sync_sec))))&&((this.send_id == rhs.send_id)||((this.send_id!= null)&&this.send_id.equals(rhs.send_id))))&&((this.port == rhs.port)||((this.port!= null)&&this.port.equals(rhs.port))))&&((this.name == rhs.name)||((this.name!= null)&&this.name.equals(rhs.name))))&&((this.periodic_sec == rhs.periodic_sec)||((this.periodic_sec!= null)&&this.periodic_sec.equals(rhs.periodic_sec))))&&((this.noConfigAck == rhs.noConfigAck)||((this.noConfigAck!= null)&&this.noConfigAck.equals(rhs.noConfigAck))))&&((this.gatewayId == rhs.gatewayId)||((this.gatewayId!= null)&&this.gatewayId.equals(rhs.gatewayId))))&&((this.auth_provider == rhs.auth_provider)||((this.auth_provider!= null)&&this.auth_provider.equals(rhs.auth_provider)))); + return (((((((((((((((((((((((((((((this.keyBytes == rhs.keyBytes)||((this.keyBytes!= null)&&this.keyBytes.equals(rhs.keyBytes)))&&((this.ca_file == rhs.ca_file)||((this.ca_file!= null)&&this.ca_file.equals(rhs.ca_file))))&&((this.key_file == rhs.key_file)||((this.key_file!= null)&&this.key_file.equals(rhs.key_file))))&&((this.cert_file == rhs.cert_file)||((this.cert_file!= null)&&this.cert_file.equals(rhs.cert_file))))&&((this.side_id == rhs.side_id)||((this.side_id!= null)&&this.side_id.equals(rhs.side_id))))&&((this.error == rhs.error)||((this.error!= null)&&this.error.equals(rhs.error))))&&((this.deviceId == rhs.deviceId)||((this.deviceId!= null)&&this.deviceId.equals(rhs.deviceId))))&&((this.client_id == rhs.client_id)||((this.client_id!= null)&&this.client_id.equals(rhs.client_id))))&&((this.enabled == rhs.enabled)||((this.enabled!= null)&&this.enabled.equals(rhs.enabled))))&&((this.capacity == rhs.capacity)||((this.capacity!= null)&&this.capacity.equals(rhs.capacity))))&&((this.protocol == rhs.protocol)||((this.protocol!= null)&&this.protocol.equals(rhs.protocol))))&&((this.hostname == rhs.hostname)||((this.hostname!= null)&&this.hostname.equals(rhs.hostname))))&&((this.payload == rhs.payload)||((this.payload!= null)&&this.payload.equals(rhs.payload))))&&((this.topic_prefix == rhs.topic_prefix)||((this.topic_prefix!= null)&&this.topic_prefix.equals(rhs.topic_prefix))))&&((this.expiry == rhs.expiry)||((this.expiry!= null)&&this.expiry.equals(rhs.expiry))))&&((this.recv_id == rhs.recv_id)||((this.recv_id!= null)&&this.recv_id.equals(rhs.recv_id))))&&((this.algorithm == rhs.algorithm)||((this.algorithm!= null)&&this.algorithm.equals(rhs.algorithm))))&&((this.generation == rhs.generation)||((this.generation!= null)&&this.generation.equals(rhs.generation))))&&((this.transport == rhs.transport)||((this.transport!= null)&&this.transport.equals(rhs.transport))))&&((this.publish_delay_sec == rhs.publish_delay_sec)||((this.publish_delay_sec!= null)&&this.publish_delay_sec.equals(rhs.publish_delay_sec))))&&((this.config_sync_sec == rhs.config_sync_sec)||((this.config_sync_sec!= null)&&this.config_sync_sec.equals(rhs.config_sync_sec))))&&((this.send_id == rhs.send_id)||((this.send_id!= null)&&this.send_id.equals(rhs.send_id))))&&((this.port == rhs.port)||((this.port!= null)&&this.port.equals(rhs.port))))&&((this.name == rhs.name)||((this.name!= null)&&this.name.equals(rhs.name))))&&((this.periodic_sec == rhs.periodic_sec)||((this.periodic_sec!= null)&&this.periodic_sec.equals(rhs.periodic_sec))))&&((this.noConfigAck == rhs.noConfigAck)||((this.noConfigAck!= null)&&this.noConfigAck.equals(rhs.noConfigAck))))&&((this.gatewayId == rhs.gatewayId)||((this.gatewayId!= null)&&this.gatewayId.equals(rhs.gatewayId))))&&((this.auth_provider == rhs.auth_provider)||((this.auth_provider!= null)&&this.auth_provider.equals(rhs.auth_provider)))); } public enum Protocol { diff --git a/gencode/java/udmi/schema/Envelope.java b/gencode/java/udmi/schema/Envelope.java index 28944ac793..923211601a 100644 --- a/gencode/java/udmi/schema/Envelope.java +++ b/gencode/java/udmi/schema/Envelope.java @@ -111,8 +111,7 @@ public enum SubFolder { POINTSET("pointset"), VALIDATION("validation"), BLOBSET("blobset"), - MONITORING("monitoring"), - STREAMS("streams"); + MONITORING("monitoring"); private final String value; private final static Map CONSTANTS = new HashMap(); @@ -161,7 +160,9 @@ public enum SubType { QUERY("query"), REPLY("reply"), MODEL("model"), - PROPOSE("propose"); + PROPOSE("propose"), + UPLOAD("upload"), + DOWNLOAD("download"); private final String value; private final static Map CONSTANTS = new HashMap(); diff --git a/gencode/java/udmi/schema/Events.java b/gencode/java/udmi/schema/Events.java index 463c9a60e5..f958acd7ce 100644 --- a/gencode/java/udmi/schema/Events.java +++ b/gencode/java/udmi/schema/Events.java @@ -43,23 +43,13 @@ public class Events { @JsonProperty("discovery") @JsonPropertyDescription("[Discovery result](../docs/specs/discovery.md) with implicit discovery") public DiscoveryEvents discovery; - /** - * Streams Events - *

- * Sequential data stream chunks for reliable transport over MQTT (e.g. PCAP traces, firmware blobs, reliable alarms, logs playback). - * - */ - @JsonProperty("streams") - @JsonPropertyDescription("Sequential data stream chunks for reliable transport over MQTT (e.g. PCAP traces, firmware blobs, reliable alarms, logs playback).") - public StreamsEvents streams; @Override public int hashCode() { int result = 1; result = ((result* 31)+((this.pointset == null)? 0 :this.pointset.hashCode())); - result = ((result* 31)+((this.system == null)? 0 :this.system.hashCode())); result = ((result* 31)+((this.discovery == null)? 0 :this.discovery.hashCode())); - result = ((result* 31)+((this.streams == null)? 0 :this.streams.hashCode())); + result = ((result* 31)+((this.system == null)? 0 :this.system.hashCode())); return result; } @@ -72,7 +62,7 @@ public boolean equals(Object other) { return false; } Events rhs = ((Events) other); - return (((((this.pointset == rhs.pointset)||((this.pointset!= null)&&this.pointset.equals(rhs.pointset)))&&((this.system == rhs.system)||((this.system!= null)&&this.system.equals(rhs.system))))&&((this.discovery == rhs.discovery)||((this.discovery!= null)&&this.discovery.equals(rhs.discovery))))&&((this.streams == rhs.streams)||((this.streams!= null)&&this.streams.equals(rhs.streams)))); + return ((((this.pointset == rhs.pointset)||((this.pointset!= null)&&this.pointset.equals(rhs.pointset)))&&((this.discovery == rhs.discovery)||((this.discovery!= null)&&this.discovery.equals(rhs.discovery))))&&((this.system == rhs.system)||((this.system!= null)&&this.system.equals(rhs.system)))); } } diff --git a/gencode/java/udmi/schema/Jwt.java b/gencode/java/udmi/schema/Jwt.java index 6b0d3bd3ce..f3b7e89a20 100644 --- a/gencode/java/udmi/schema/Jwt.java +++ b/gencode/java/udmi/schema/Jwt.java @@ -3,6 +3,7 @@ import com.fasterxml.jackson.annotation.JsonInclude; import com.fasterxml.jackson.annotation.JsonProperty; +import com.fasterxml.jackson.annotation.JsonPropertyDescription; import com.fasterxml.jackson.annotation.JsonPropertyOrder; @JsonInclude(JsonInclude.Include.NON_NULL) @@ -10,11 +11,19 @@ public class Jwt { @JsonProperty("audience") public String audience; + /** + * Pre-signed JWT token or credential string + * + */ + @JsonProperty("token") + @JsonPropertyDescription("Pre-signed JWT token or credential string") + public String token; @Override public int hashCode() { int result = 1; result = ((result* 31)+((this.audience == null)? 0 :this.audience.hashCode())); + result = ((result* 31)+((this.token == null)? 0 :this.token.hashCode())); return result; } @@ -27,7 +36,7 @@ public boolean equals(Object other) { return false; } Jwt rhs = ((Jwt) other); - return ((this.audience == rhs.audience)||((this.audience!= null)&&this.audience.equals(rhs.audience))); + return (((this.audience == rhs.audience)||((this.audience!= null)&&this.audience.equals(rhs.audience)))&&((this.token == rhs.token)||((this.token!= null)&&this.token.equals(rhs.token)))); } } diff --git a/gencode/java/udmi/schema/Mtls.java b/gencode/java/udmi/schema/Mtls.java new file mode 100644 index 0000000000..4fc03a1d42 --- /dev/null +++ b/gencode/java/udmi/schema/Mtls.java @@ -0,0 +1,55 @@ + +package udmi.schema; + +import com.fasterxml.jackson.annotation.JsonInclude; +import com.fasterxml.jackson.annotation.JsonProperty; +import com.fasterxml.jackson.annotation.JsonPropertyDescription; +import com.fasterxml.jackson.annotation.JsonPropertyOrder; + +@JsonInclude(JsonInclude.Include.NON_NULL) +public class Mtls { + + /** + * PEM-encoded private key material for mTLS authentication + * + */ + @JsonProperty("private_key") + @JsonPropertyDescription("PEM-encoded private key material for mTLS authentication") + public String private_key; + /** + * PEM-encoded client certificate for mTLS authentication + * + */ + @JsonProperty("certificate") + @JsonPropertyDescription("PEM-encoded client certificate for mTLS authentication") + public String certificate; + /** + * PEM-encoded CA certificate bundle for peer verification + * + */ + @JsonProperty("ca_certificate") + @JsonPropertyDescription("PEM-encoded CA certificate bundle for peer verification") + public String ca_certificate; + + @Override + public int hashCode() { + int result = 1; + result = ((result* 31)+((this.certificate == null)? 0 :this.certificate.hashCode())); + result = ((result* 31)+((this.ca_certificate == null)? 0 :this.ca_certificate.hashCode())); + result = ((result* 31)+((this.private_key == null)? 0 :this.private_key.hashCode())); + return result; + } + + @Override + public boolean equals(Object other) { + if (other == this) { + return true; + } + if ((other instanceof Mtls) == false) { + return false; + } + Mtls rhs = ((Mtls) other); + return ((((this.certificate == rhs.certificate)||((this.certificate!= null)&&this.certificate.equals(rhs.certificate)))&&((this.ca_certificate == rhs.ca_certificate)||((this.ca_certificate!= null)&&this.ca_certificate.equals(rhs.ca_certificate))))&&((this.private_key == rhs.private_key)||((this.private_key!= null)&&this.private_key.equals(rhs.private_key)))); + } + +} diff --git a/gencode/java/udmi/schema/StreamsEvents.java b/gencode/java/udmi/schema/StreamsEvents.java deleted file mode 100644 index 4794855dd9..0000000000 --- a/gencode/java/udmi/schema/StreamsEvents.java +++ /dev/null @@ -1,95 +0,0 @@ - -package udmi.schema; - -import java.util.Date; -import com.fasterxml.jackson.annotation.JsonInclude; -import com.fasterxml.jackson.annotation.JsonProperty; -import com.fasterxml.jackson.annotation.JsonPropertyDescription; -import com.fasterxml.jackson.annotation.JsonPropertyOrder; - - -/** - * Streams Events - *

- * Sequential data stream chunks for reliable transport over MQTT (e.g. PCAP traces, firmware blobs, reliable alarms, logs playback). - * - */ -@JsonInclude(JsonInclude.Include.NON_NULL) -public class StreamsEvents { - - /** - * RFC 3339 UTC timestamp the stream chunk event was generated - * - */ - @JsonProperty("timestamp") - @JsonPropertyDescription("RFC 3339 UTC timestamp the stream chunk event was generated") - public Date timestamp; - /** - * Version of the UDMI schema - * - */ - @JsonProperty("version") - @JsonPropertyDescription("Version of the UDMI schema") - public String version; - /** - * Unique session identifier for the continuous stream transmission - * - */ - @JsonProperty("session_id") - @JsonPropertyDescription("Unique session identifier for the continuous stream transmission") - public String session_id; - /** - * Sequence number for this stream event to support reliable sequential delivery and reassembly - * - */ - @JsonProperty("event_no") - @JsonPropertyDescription("Sequence number for this stream event to support reliable sequential delivery and reassembly") - public Integer event_no; - /** - * 0-based index of the transmitted chunk payload within the total session - * - */ - @JsonProperty("chunk_index") - @JsonPropertyDescription("0-based index of the transmitted chunk payload within the total session") - public Integer chunk_index; - /** - * Total number of chunks comprising the complete transmission binary - * - */ - @JsonProperty("total_chunks") - @JsonPropertyDescription("Total number of chunks comprising the complete transmission binary") - public Integer total_chunks; - /** - * Base64-encoded binary chunk payload data - * - */ - @JsonProperty("data") - @JsonPropertyDescription("Base64-encoded binary chunk payload data") - public String data; - - @Override - public int hashCode() { - int result = 1; - result = ((result* 31)+((this.chunk_index == null)? 0 :this.chunk_index.hashCode())); - result = ((result* 31)+((this.data == null)? 0 :this.data.hashCode())); - result = ((result* 31)+((this.event_no == null)? 0 :this.event_no.hashCode())); - result = ((result* 31)+((this.session_id == null)? 0 :this.session_id.hashCode())); - result = ((result* 31)+((this.version == null)? 0 :this.version.hashCode())); - result = ((result* 31)+((this.total_chunks == null)? 0 :this.total_chunks.hashCode())); - result = ((result* 31)+((this.timestamp == null)? 0 :this.timestamp.hashCode())); - return result; - } - - @Override - public boolean equals(Object other) { - if (other == this) { - return true; - } - if ((other instanceof StreamsEvents) == false) { - return false; - } - StreamsEvents rhs = ((StreamsEvents) other); - return ((((((((this.chunk_index == rhs.chunk_index)||((this.chunk_index!= null)&&this.chunk_index.equals(rhs.chunk_index)))&&((this.data == rhs.data)||((this.data!= null)&&this.data.equals(rhs.data))))&&((this.event_no == rhs.event_no)||((this.event_no!= null)&&this.event_no.equals(rhs.event_no))))&&((this.session_id == rhs.session_id)||((this.session_id!= null)&&this.session_id.equals(rhs.session_id))))&&((this.version == rhs.version)||((this.version!= null)&&this.version.equals(rhs.version))))&&((this.total_chunks == rhs.total_chunks)||((this.total_chunks!= null)&&this.total_chunks.equals(rhs.total_chunks))))&&((this.timestamp == rhs.timestamp)||((this.timestamp!= null)&&this.timestamp.equals(rhs.timestamp)))); - } - -} diff --git a/gencode/presentation/presentation.json b/gencode/presentation/presentation.json index 98cb150aa0..12344befe7 100644 --- a/gencode/presentation/presentation.json +++ b/gencode/presentation/presentation.json @@ -440,6 +440,27 @@ "section": "cloud_iot_config", "type": "string" }, + "reflector_endpoint.auth_provider.mtls.private_key": { + "display": "show", + "style": "bold", + "section": "cloud_iot_config", + "type": "string", + "description": "PEM-encoded private key material for mTLS authentication" + }, + "reflector_endpoint.auth_provider.mtls.certificate": { + "display": "show", + "style": "bold", + "section": "cloud_iot_config", + "type": "string", + "description": "PEM-encoded client certificate for mTLS authentication" + }, + "reflector_endpoint.auth_provider.mtls.ca_certificate": { + "display": "show", + "style": "bold", + "section": "cloud_iot_config", + "type": "string", + "description": "PEM-encoded CA certificate bundle for peer verification" + }, "reflector_endpoint.ca_file": { "display": "show", "style": "bold", @@ -471,6 +492,16 @@ "2019-01-17T14:02:29.364Z" ] }, + "reflector_endpoint.expiry": { + "display": "show", + "style": "bold", + "section": "cloud_iot_config", + "type": "string", + "description": "Timestamp when the provisioned credential or endpoint expires", + "examples": [ + "2019-01-17T14:02:29.364Z" + ] + }, "device_endpoint.name": { "display": "show", "style": "bold", @@ -613,6 +644,27 @@ "section": "cloud_iot_config", "type": "string" }, + "device_endpoint.auth_provider.mtls.private_key": { + "display": "show", + "style": "bold", + "section": "cloud_iot_config", + "type": "string", + "description": "PEM-encoded private key material for mTLS authentication" + }, + "device_endpoint.auth_provider.mtls.certificate": { + "display": "show", + "style": "bold", + "section": "cloud_iot_config", + "type": "string", + "description": "PEM-encoded client certificate for mTLS authentication" + }, + "device_endpoint.auth_provider.mtls.ca_certificate": { + "display": "show", + "style": "bold", + "section": "cloud_iot_config", + "type": "string", + "description": "PEM-encoded CA certificate bundle for peer verification" + }, "device_endpoint.ca_file": { "display": "show", "style": "bold", @@ -644,6 +696,16 @@ "2019-01-17T14:02:29.364Z" ] }, + "device_endpoint.expiry": { + "display": "show", + "style": "bold", + "section": "cloud_iot_config", + "type": "string", + "description": "Timestamp when the provisioned credential or endpoint expires", + "examples": [ + "2019-01-17T14:02:29.364Z" + ] + }, "project_id": { "display": "show", "style": "bold", diff --git a/gencode/python/udmi/schema/__init__.py b/gencode/python/udmi/schema/__init__.py index f7e8dd0b75..58a191810e 100644 --- a/gencode/python/udmi/schema/__init__.py +++ b/gencode/python/udmi/schema/__init__.py @@ -34,6 +34,7 @@ from .discovery_family import * from .discovery_feature import * from .discovery_ref import * +from .download_blobset import * from .entry import * from .envelope import * from .equipment_translation import * @@ -43,7 +44,6 @@ from .events_discovery import * from .events_pointset import * from .events_pointset_point import * -from .events_streams import * from .events_system import * from .events_udmi import * from .events_validation import * @@ -101,3 +101,4 @@ from .state_validation_feature import * from .state_validation_schema import * from .state_validation_sequence import * +from .upload_discovery import * diff --git a/gencode/python/udmi/schema/common.py b/gencode/python/udmi/schema/common.py index c6a3b1b7c0..674ce793c0 100644 --- a/gencode/python/udmi/schema/common.py +++ b/gencode/python/udmi/schema/common.py @@ -93,6 +93,8 @@ class Blobsets(Enum): """ field_iot_endpoint_config = '_iot_endpoint_config' + field_bacnet_sc_config = '_bacnet_sc_config' + field_discovery_auth = '_discovery_auth' @dataclass diff --git a/gencode/python/udmi/schema/configuration_endpoint.py b/gencode/python/udmi/schema/configuration_endpoint.py index 954286596b..1f1d859085 100644 --- a/gencode/python/udmi/schema/configuration_endpoint.py +++ b/gencode/python/udmi/schema/configuration_endpoint.py @@ -32,12 +32,21 @@ class Basic(DataModel): @dataclass class Jwt(DataModel): audience: Optional[str] = None + token: Optional[str] = None + + +@dataclass +class Mtls(DataModel): + private_key: Optional[str] = None + certificate: Optional[str] = None + ca_certificate: Optional[str] = None @dataclass class AuthProvider(DataModel): basic: Optional[Basic] = None jwt: Optional[Jwt] = None + mtls: Optional[Mtls] = None @dataclass @@ -73,3 +82,4 @@ class EndpointConfiguration(DataModel): cert_file: Optional[str] = None key_file: Optional[str] = None generation: Optional[str] = None + expiry: Optional[str] = None diff --git a/gencode/python/udmi/schema/download_blobset.py b/gencode/python/udmi/schema/download_blobset.py new file mode 100644 index 0000000000..a583146b4d --- /dev/null +++ b/gencode/python/udmi/schema/download_blobset.py @@ -0,0 +1,21 @@ +# generated by datamodel-codegen: +# filename: download_blobset.json + +from __future__ import annotations + +from dataclasses import dataclass +from typing import Dict, Optional + +from ._base import DataModel +from .configuration_endpoint import EndpointConfiguration + + +@dataclass +class BlobsetDownload(DataModel): + """ + Direct cloud-to-device payload delivery for blobset configurations over the download/blobset channel. + """ + + timestamp: Optional[str] = None + version: Optional[str] = None + blobs: Optional[Dict[str, EndpointConfiguration]] = None diff --git a/gencode/python/udmi/schema/envelope.py b/gencode/python/udmi/schema/envelope.py index 08cdb8509e..d363fcdffb 100644 --- a/gencode/python/udmi/schema/envelope.py +++ b/gencode/python/udmi/schema/envelope.py @@ -26,7 +26,6 @@ class SubFolder(Enum): validation = 'validation' blobset = 'blobset' monitoring = 'monitoring' - streams = 'streams' class SubType(Enum): @@ -42,6 +41,8 @@ class SubType(Enum): reply = 'reply' model = 'model' propose = 'propose' + upload = 'upload' + download = 'download' @dataclass diff --git a/gencode/python/udmi/schema/events.py b/gencode/python/udmi/schema/events.py index 3ac694d27a..d932e899e5 100644 --- a/gencode/python/udmi/schema/events.py +++ b/gencode/python/udmi/schema/events.py @@ -9,7 +9,6 @@ from ._base import DataModel from .events_discovery import DiscoveryEvents from .events_pointset import PointsetEvents -from .events_streams import StreamsEvents from .events_system import SystemEvents @@ -22,4 +21,3 @@ class Events(DataModel): system: Optional[SystemEvents] = None pointset: Optional[PointsetEvents] = None discovery: Optional[DiscoveryEvents] = None - streams: Optional[StreamsEvents] = None diff --git a/gencode/python/udmi/schema/events_streams.py b/gencode/python/udmi/schema/upload_discovery.py similarity index 59% rename from gencode/python/udmi/schema/events_streams.py rename to gencode/python/udmi/schema/upload_discovery.py index e1f344a814..d2448020a8 100644 --- a/gencode/python/udmi/schema/events_streams.py +++ b/gencode/python/udmi/schema/upload_discovery.py @@ -1,5 +1,5 @@ # generated by datamodel-codegen: -# filename: events_streams.json +# filename: upload_discovery.json from __future__ import annotations @@ -10,15 +10,18 @@ @dataclass -class StreamsEvents(DataModel): +class DiscoveryUpload(DataModel): """ - Sequential data stream chunks for reliable transport over MQTT (e.g. PCAP traces, firmware blobs, reliable alarms, logs playback). + Sequential binary chunk upload for discovery artifacts (e.g. PCAP traces) framed by config.discovery and state.discovery. """ timestamp: Optional[str] = None version: Optional[str] = None + family: Optional[str] = None + generation: Optional[str] = None session_id: Optional[str] = None event_no: Optional[int] = None chunk_index: Optional[int] = None total_chunks: Optional[int] = None + sha256: Optional[str] = None data: Optional[str] = None diff --git a/misc/bambi/presentation.gld.json b/misc/bambi/presentation.gld.json index 98cb150aa0..12344befe7 100644 --- a/misc/bambi/presentation.gld.json +++ b/misc/bambi/presentation.gld.json @@ -440,6 +440,27 @@ "section": "cloud_iot_config", "type": "string" }, + "reflector_endpoint.auth_provider.mtls.private_key": { + "display": "show", + "style": "bold", + "section": "cloud_iot_config", + "type": "string", + "description": "PEM-encoded private key material for mTLS authentication" + }, + "reflector_endpoint.auth_provider.mtls.certificate": { + "display": "show", + "style": "bold", + "section": "cloud_iot_config", + "type": "string", + "description": "PEM-encoded client certificate for mTLS authentication" + }, + "reflector_endpoint.auth_provider.mtls.ca_certificate": { + "display": "show", + "style": "bold", + "section": "cloud_iot_config", + "type": "string", + "description": "PEM-encoded CA certificate bundle for peer verification" + }, "reflector_endpoint.ca_file": { "display": "show", "style": "bold", @@ -471,6 +492,16 @@ "2019-01-17T14:02:29.364Z" ] }, + "reflector_endpoint.expiry": { + "display": "show", + "style": "bold", + "section": "cloud_iot_config", + "type": "string", + "description": "Timestamp when the provisioned credential or endpoint expires", + "examples": [ + "2019-01-17T14:02:29.364Z" + ] + }, "device_endpoint.name": { "display": "show", "style": "bold", @@ -613,6 +644,27 @@ "section": "cloud_iot_config", "type": "string" }, + "device_endpoint.auth_provider.mtls.private_key": { + "display": "show", + "style": "bold", + "section": "cloud_iot_config", + "type": "string", + "description": "PEM-encoded private key material for mTLS authentication" + }, + "device_endpoint.auth_provider.mtls.certificate": { + "display": "show", + "style": "bold", + "section": "cloud_iot_config", + "type": "string", + "description": "PEM-encoded client certificate for mTLS authentication" + }, + "device_endpoint.auth_provider.mtls.ca_certificate": { + "display": "show", + "style": "bold", + "section": "cloud_iot_config", + "type": "string", + "description": "PEM-encoded CA certificate bundle for peer verification" + }, "device_endpoint.ca_file": { "display": "show", "style": "bold", @@ -644,6 +696,16 @@ "2019-01-17T14:02:29.364Z" ] }, + "device_endpoint.expiry": { + "display": "show", + "style": "bold", + "section": "cloud_iot_config", + "type": "string", + "description": "Timestamp when the provisioned credential or endpoint expires", + "examples": [ + "2019-01-17T14:02:29.364Z" + ] + }, "project_id": { "display": "show", "style": "bold", diff --git a/pubber/src/main/java/udmi/lib/base/MqttDevice.java b/pubber/src/main/java/udmi/lib/base/MqttDevice.java index 804f6e6912..dc8a82ead9 100644 --- a/pubber/src/main/java/udmi/lib/base/MqttDevice.java +++ b/pubber/src/main/java/udmi/lib/base/MqttDevice.java @@ -20,6 +20,8 @@ public class MqttDevice { public static final String ERRORS_TOPIC = "errors"; public static final String EVENTS_TOPIC = "events"; public static final String STATE_TOPIC = "state"; + public static final String DOWNLOAD_TOPIC = "download/blobset"; + public static final String UPLOAD_TOPIC = "upload"; private final String deviceId; private final Publisher publisher; diff --git a/pubber/src/main/java/udmi/lib/base/MqttPublisher.java b/pubber/src/main/java/udmi/lib/base/MqttPublisher.java index a2605f6067..6a6981edd9 100644 --- a/pubber/src/main/java/udmi/lib/base/MqttPublisher.java +++ b/pubber/src/main/java/udmi/lib/base/MqttPublisher.java @@ -498,6 +498,8 @@ private void subscribeToUpdates(MqttClient client, String deviceId) { if (configuration.recv_id == null) { subscribeTopic(client, getMessageTopic(deviceId, MqttDevice.CONFIG_TOPIC), configQos); subscribeTopic(client, getMessageTopic(deviceId, MqttDevice.ERRORS_TOPIC), QOS_AT_MOST_ONCE); + subscribeTopic( + client, getMessageTopic(deviceId, MqttDevice.DOWNLOAD_TOPIC), QOS_AT_MOST_ONCE); } else { subscribeTopic(client, configuration.recv_id, configQos); } diff --git a/pubber/src/main/java/udmi/lib/client/host/PublisherHost.java b/pubber/src/main/java/udmi/lib/client/host/PublisherHost.java index 0d7c3e88a0..58ca4d98d4 100644 --- a/pubber/src/main/java/udmi/lib/client/host/PublisherHost.java +++ b/pubber/src/main/java/udmi/lib/client/host/PublisherHost.java @@ -23,6 +23,7 @@ import static udmi.lib.base.ManagerBase.INITIAL_THRESHOLD_SEC; import static udmi.lib.base.ManagerBase.updateStateHolder; import static udmi.lib.base.MqttDevice.CONFIG_TOPIC; +import static udmi.lib.base.MqttDevice.DOWNLOAD_TOPIC; import static udmi.lib.base.MqttDevice.ERRORS_TOPIC; import static udmi.lib.base.MqttDevice.STATE_TOPIC; import static udmi.lib.base.MqttPublisher.DEFAULT_CONFIG_WAIT_SEC; @@ -75,11 +76,13 @@ import udmi.schema.BlobBlobsetState; import udmi.schema.BlobsetConfig; import udmi.schema.BlobsetConfig.SystemBlobsets; +import udmi.schema.BlobsetDownload; import udmi.schema.BlobsetState; import udmi.schema.Category; import udmi.schema.Config; import udmi.schema.DevicePersistent; import udmi.schema.DiscoveryEvents; +import udmi.schema.DiscoveryUpload; import udmi.schema.EndpointConfiguration; import udmi.schema.Entry; import udmi.schema.Level; @@ -100,6 +103,7 @@ public interface PublisherHost extends ManagerHost { String UDMI_VERSION = SchemaVersion.CURRENT.key(); String BROKEN_VERSION = "1.4."; String DATA_URL_JSON_BASE64 = "data:application/json;base64,"; + String DOWNLOAD_URL_PREFIX = "download://"; int STATE_THROTTLE_MS = 2000; int DEFAULT_REPORT_SEC = 10; int FORCED_STATE_TIME_MS = 10000; @@ -122,6 +126,7 @@ public interface PublisherHost extends ManagerHost { .put(MqttPublisher.FakeTopic.class, getEventsSuffix("racoon")) .put(MqttPublisher.InjectedState.class, STATE_TOPIC) .put(DiscoveryEvents.class, getEventsSuffix("discovery")) + .put(DiscoveryUpload.class, getUploadSuffix("discovery")) .build(); Map INVALID_REPLACEMENTS = ImmutableMap.of( "events/blobset", "\"\"", @@ -258,6 +263,17 @@ default String extractConfigBlob(String blobName) { } BlobBlobsetConfig blobBlobsetConfig = blobs.get(blobName); if (blobBlobsetConfig != null && FINAL.equals(blobBlobsetConfig.phase)) { + if (blobBlobsetConfig.url != null + && blobBlobsetConfig.url.startsWith(DOWNLOAD_URL_PREFIX)) { + BlobBlobsetState blobState = ensureBlobsetState(blobName); + if (!Objects.equals(blobState.generation, blobBlobsetConfig.generation) + || !BlobPhase.FINAL.equals(blobState.phase)) { + blobState.phase = BlobPhase.APPLY; + blobState.generation = blobBlobsetConfig.generation; + markStateDirty(); + } + return null; + } return acquireBlobData(blobBlobsetConfig.url, blobBlobsetConfig.sha256); } return null; @@ -437,6 +453,10 @@ static String getEventsSuffix(String suffixSuffix) { return format("%s/%s", MqttDevice.EVENTS_TOPIC, suffixSuffix); } + static String getUploadSuffix(String suffixSuffix) { + return format("%s/%s", MqttDevice.UPLOAD_TOPIC, suffixSuffix); + } + /** * Augments a given {@code message} object with the current timestamp and version information. */ @@ -559,6 +579,7 @@ default void disconnectMqtt() { */ default void registerMessageHandlers() { getDeviceTarget().registerHandler(CONFIG_TOPIC, this::configHandler, Config.class); + getDeviceTarget().registerHandler(DOWNLOAD_TOPIC, this::downloadHandler, BlobsetDownload.class); String gatewayId = getGatewayId(getDeviceId()); if (isGatewayDevice()) { // In this case, this is the gateway so register the appropriate error handler directly. @@ -571,6 +592,26 @@ default void registerMessageHandlers() { } } + /** + * Handles incoming ephemeral {@link BlobsetDownload} payloads on {@code download/blobset}. + */ + default void downloadHandler(BlobsetDownload download) { + if (download == null || download.blobs == null) { + return; + } + withStateLock(() -> { + EndpointConfiguration endpoint = download.blobs.get(IOT_ENDPOINT_CONFIG.value()); + if (endpoint != null) { + BlobBlobsetConfig blobConfig = + catchToNull(() -> getDeviceConfig().blobset.blobs.get(IOT_ENDPOINT_CONFIG.value())); + if (blobConfig != null && blobConfig.generation != null) { + endpoint.generation = blobConfig.generation; + } + setExtractedEndpoint(endpoint); + } + }); + } + /** * Get MqttDevice for given proxy. * @@ -942,6 +983,12 @@ default void maybeRedirectEndpoint(EndpointConfiguration extractedEndpoint) { : redirectedEndpoint(redirectRegistry); if (extractedSignature == null) { + BlobBlobsetConfig pendingBlob = + catchToNull(() -> getDeviceConfig().blobset.blobs.get(IOT_ENDPOINT_CONFIG.value())); + if (pendingBlob != null && pendingBlob.url != null + && pendingBlob.url.startsWith(DOWNLOAD_URL_PREFIX)) { + return; + } setAttemptedEndpoint(null); removeBlobsetBlobState(IOT_ENDPOINT_CONFIG); return; diff --git a/schema/common.json b/schema/common.json index 26b9835131..f5f65430b6 100644 --- a/schema/common.json +++ b/schema/common.json @@ -97,7 +97,9 @@ "title": "System Blobsets", "description": "Predefined system blobsets", "enum": [ - "_iot_endpoint_config" + "_iot_endpoint_config", + "_bacnet_sc_config", + "_discovery_auth" ] } } diff --git a/schema/configuration_endpoint.json b/schema/configuration_endpoint.json index 1a255bd5cd..62763bb627 100644 --- a/schema/configuration_endpoint.json +++ b/schema/configuration_endpoint.json @@ -219,6 +219,44 @@ "display": "show", "style": "bold" } + }, + "token": { + "description": "Pre-signed JWT token or credential string", + "type": "string", + "$presentation": { + "display": "show", + "style": "bold" + } + } + } + }, + "mtls": { + "type": "object", + "additionalProperties": false, + "properties": { + "private_key": { + "description": "PEM-encoded private key material for mTLS authentication", + "type": "string", + "$presentation": { + "display": "show", + "style": "bold" + } + }, + "certificate": { + "description": "PEM-encoded client certificate for mTLS authentication", + "type": "string", + "$presentation": { + "display": "show", + "style": "bold" + } + }, + "ca_certificate": { + "description": "PEM-encoded CA certificate bundle for peer verification", + "type": "string", + "$presentation": { + "display": "show", + "style": "bold" + } } } } @@ -257,6 +295,16 @@ "display": "show", "style": "bold" } + }, + "expiry": { + "description": "Timestamp when the provisioned credential or endpoint expires", + "type": "string", + "format": "date-time", + "examples": ["2019-01-17T14:02:29.364Z"], + "$presentation": { + "display": "show", + "style": "bold" + } } }, "required": [ diff --git a/schema/download_blobset.json b/schema/download_blobset.json new file mode 100644 index 0000000000..678a9745fc --- /dev/null +++ b/schema/download_blobset.json @@ -0,0 +1,37 @@ +{ + "$udmi_version": "1.5.7", + "title": "Blobset Download", + "description": "Direct cloud-to-device payload delivery for blobset configurations over the download/blobset channel.", + "$section": "Messages", + "type": "object", + "$schema": "http://json-schema.org/draft-07/schema#", + "additionalProperties": false, + "properties": { + "timestamp": { + "description": "RFC 3339 UTC timestamp the download payload was generated", + "type": "string", + "format": "date-time", + "examples": ["2019-01-17T14:02:29.364Z"] + }, + "version": { + "description": "Version of the UDMI schema", + "type": "string" + }, + "blobs": { + "description": "Map of system blobset keys to their provisioned endpoint configuration payloads", + "type": "object", + "additionalProperties": false, + "existingJavaType": "java.util.HashMap", + "patternProperties": { + "^_[a-z_]+$": { + "$ref": "file:configuration_endpoint.json#" + } + } + } + }, + "required": [ + "timestamp", + "version", + "blobs" + ] +} diff --git a/schema/envelope.json b/schema/envelope.json index 8dac2d7f1c..bf92562c1d 100644 --- a/schema/envelope.json +++ b/schema/envelope.json @@ -68,8 +68,7 @@ "pointset", "validation", "blobset", - "monitoring", - "streams" + "monitoring" ] }, "subType": { @@ -85,7 +84,9 @@ "query", "reply", "model", - "propose" + "propose", + "upload", + "download" ] } }, diff --git a/schema/events.json b/schema/events.json index 9ae8ec7de3..3d8d0e387a 100644 --- a/schema/events.json +++ b/schema/events.json @@ -14,9 +14,6 @@ }, "discovery": { "$ref": "file:events_discovery.json#" - }, - "streams": { - "$ref": "file:events_streams.json#" } } } diff --git a/schema/events_streams.json b/schema/events_streams.json deleted file mode 100644 index b9df2fcade..0000000000 --- a/schema/events_streams.json +++ /dev/null @@ -1,40 +0,0 @@ -{ - "$udmi_version": "1.5.7", - "title": "Streams Events", - "description": "Sequential data stream chunks for reliable transport over MQTT (e.g. PCAP traces, firmware blobs, reliable alarms, logs playback).", - "$section": "Messages", - "type": "object", - "$schema": "http://json-schema.org/draft-07/schema#", - "additionalProperties": false, - "properties": { - "timestamp": { - "description": "RFC 3339 UTC timestamp the stream chunk event was generated", - "type": "string", - "format": "date-time" - }, - "version": { - "description": "Version of the UDMI schema", - "type": "string" - }, - "session_id": { - "description": "Unique session identifier for the continuous stream transmission", - "type": "string" - }, - "event_no": { - "description": "Sequence number for this stream event to support reliable sequential delivery and reassembly", - "type": "integer" - }, - "chunk_index": { - "description": "0-based index of the transmitted chunk payload within the total session", - "type": "integer" - }, - "total_chunks": { - "description": "Total number of chunks comprising the complete transmission binary", - "type": "integer" - }, - "data": { - "description": "Base64-encoded binary chunk payload data", - "type": "string" - } - } -} diff --git a/schema/upload_discovery.json b/schema/upload_discovery.json new file mode 100644 index 0000000000..5511fe99d6 --- /dev/null +++ b/schema/upload_discovery.json @@ -0,0 +1,62 @@ +{ + "$udmi_version": "1.5.7", + "title": "Discovery Upload", + "description": "Sequential binary chunk upload for discovery artifacts (e.g. PCAP traces) framed by config.discovery and state.discovery.", + "$section": "Messages", + "type": "object", + "$schema": "http://json-schema.org/draft-07/schema#", + "additionalProperties": false, + "properties": { + "timestamp": { + "description": "RFC 3339 UTC timestamp the upload chunk was generated", + "type": "string", + "format": "date-time", + "examples": ["2019-01-17T14:02:29.364Z"] + }, + "version": { + "description": "Version of the UDMI schema", + "type": "string" + }, + "family": { + "description": "Protocol family associated with the discovery trace upload", + "type": "string" + }, + "generation": { + "description": "The discovery scan trigger's generation timestamp", + "type": "string", + "format": "date-time", + "examples": ["2019-01-17T14:02:29.364Z"] + }, + "session_id": { + "description": "Unique session identifier for the chunked upload transmission", + "type": "string" + }, + "event_no": { + "description": "Sequence number for this upload chunk: 0 for start marker, 1..N for data chunks, -(N + 1) for terminal EOF marker", + "type": "integer" + }, + "chunk_index": { + "description": "0-based index of the transmitted chunk payload within the total session", + "type": "integer" + }, + "total_chunks": { + "description": "Total number of chunks comprising the complete transmission binary", + "type": "integer" + }, + "sha256": { + "description": "SHA-256 hex digest of the complete unencoded binary payload (included on the terminal negative event_no marker)", + "type": "string", + "pattern": "^[0-9a-fA-F]{64}$" + }, + "data": { + "description": "Base64-encoded binary chunk payload data", + "type": "string" + } + }, + "required": [ + "timestamp", + "version", + "session_id", + "event_no" + ] +} diff --git a/tests/schemas/download_blobset/endpoint.json b/tests/schemas/download_blobset/endpoint.json new file mode 100644 index 0000000000..bf5d7e5627 --- /dev/null +++ b/tests/schemas/download_blobset/endpoint.json @@ -0,0 +1,30 @@ +// +// Sent on MQTT topic: /devices/{device_id}/download/blobset +// Ephemeral cloud-to-device blobset delivery. +// +{ + "version": "1.5.7", + "timestamp": "2019-01-17T14:02:29.364Z", + "blobs": { + "_iot_endpoint_config": { + "protocol": "mqtt", + "transport": "ssl", + "hostname": "mqtt.example.com", + "port": 8883, + "client_id": "AHU-1", + "generation": "2019-01-17T14:02:00.000Z", + "expiry": "2019-01-18T14:02:00.000Z", + "auth_provider": { + "jwt": { + "audience": "example-project", + "token": "eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9.payload.signature" + }, + "mtls": { + "private_key": "-----BEGIN PRIVATE KEY-----\nMIIEvQIBADANBgkqhkiG9w0BAQEFAASCBKcwggSjAgEAAoIBAQC\n-----END PRIVATE KEY-----", + "certificate": "-----BEGIN CERTIFICATE-----\nMIIDXTCCAkWgAwIBAgIJALa\n-----END CERTIFICATE-----", + "ca_certificate": "-----BEGIN CERTIFICATE-----\nMIIDxTCCAq2gAwIBAgIQAqxcJmoLQJuPC3nyrkYldzANBgkqhkiG9w0BAQUFADBs\n-----END CERTIFICATE-----" + } + } + } + } +} diff --git a/tests/schemas/download_blobset/endpoint.out b/tests/schemas/download_blobset/endpoint.out new file mode 100644 index 0000000000..e69de29bb2 diff --git a/tests/schemas/envelope/errors1.out b/tests/schemas/envelope/errors1.out index e47771c99a..b700b4be53 100644 --- a/tests/schemas/envelope/errors1.out +++ b/tests/schemas/envelope/errors1.out @@ -2,4 +2,4 @@ /deviceId: ECMA 262 regex "^[A-Z]{2,6}-[1-9][0-9]*$" does not match input string "fcu-1" /deviceNumId: ECMA 262 regex "^[0-9]+$" does not match input string "921302198324X" /deviceRegistryId: ECMA 262 regex "^[a-zA-Z][-a-zA-Z0-9._+~%]*[a-zA-Z0-9]$" does not match input string "test/registry" - /subType: instance value (5) not found in enum (possible values: ["invalid","local","reflect","events","errors","commands","config","state","query","reply","model","propose"]) + /subType: instance value (5) not found in enum (possible values: ["invalid","local","reflect","events","errors","commands","config","state","query","reply","model","propose","upload","download"]) diff --git a/tests/schemas/upload_discovery/chunk.json b/tests/schemas/upload_discovery/chunk.json new file mode 100644 index 0000000000..59398f8efc --- /dev/null +++ b/tests/schemas/upload_discovery/chunk.json @@ -0,0 +1,15 @@ +// +// Sent on MQTT topic: /devices/{device_id}/upload/discovery +// Sequential binary chunk upload for a discovery PCAP trace. +// +{ + "version": "1.5.7", + "timestamp": "2019-01-17T14:02:29.364Z", + "family": "bacnet", + "generation": "2019-01-17T14:02:00.000Z", + "session_id": "trace-bacnet-2019-01-17T14-02-00Z", + "event_no": 1, + "chunk_index": 0, + "total_chunks": 2, + "data": "dWRtaS1wY2FwLWNodW5rLWRhdGE=" +} diff --git a/tests/schemas/upload_discovery/chunk.out b/tests/schemas/upload_discovery/chunk.out new file mode 100644 index 0000000000..e69de29bb2 diff --git a/tests/schemas/upload_discovery/eof.json b/tests/schemas/upload_discovery/eof.json new file mode 100644 index 0000000000..d0fbe97490 --- /dev/null +++ b/tests/schemas/upload_discovery/eof.json @@ -0,0 +1,13 @@ +// +// Terminal EOF boundary marker on /devices/{device_id}/upload/discovery +// +{ + "version": "1.5.7", + "timestamp": "2019-01-17T14:02:31.364Z", + "family": "bacnet", + "generation": "2019-01-17T14:02:00.000Z", + "session_id": "trace-bacnet-2019-01-17T14-02-00Z", + "event_no": -3, + "total_chunks": 2, + "sha256": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855" +} diff --git a/tests/schemas/upload_discovery/eof.out b/tests/schemas/upload_discovery/eof.out new file mode 100644 index 0000000000..e69de29bb2 diff --git a/udmis/etc/local_pod.json b/udmis/etc/local_pod.json index 10b6581621..99233d0548 100644 --- a/udmis/etc/local_pod.json +++ b/udmis/etc/local_pod.json @@ -22,6 +22,9 @@ "target": { "recv_id": "events/#" }, + "upload": { + "recv_id": "upload/#" + }, "state": { "recv_id": "state" }, diff --git a/udmis/src/main/java/com/google/bos/udmi/service/access/DynamicIotAccessProvider.java b/udmis/src/main/java/com/google/bos/udmi/service/access/DynamicIotAccessProvider.java index c1c74a3133..77f5231ded 100644 --- a/udmis/src/main/java/com/google/bos/udmi/service/access/DynamicIotAccessProvider.java +++ b/udmis/src/main/java/com/google/bos/udmi/service/access/DynamicIotAccessProvider.java @@ -215,6 +215,25 @@ public void sendCommandBase(Envelope envelope, SubFolder folder, getProviderFor(envelope).sendCommandBase(envelope, folder, message); } + @Override + public void sendDownloadBase(Envelope envelope, SubFolder folder, String message) { + getProviderFor(envelope).sendDownloadBase(envelope, folder, message); + } + + @Override + public void provisionDownloadBlob(String registryId, String deviceId, String blobKey, + udmi.schema.EndpointConfiguration endpoint) { + getRegistryProvider(registryId, deviceId) + .provisionDownloadBlob(registryId, deviceId, blobKey, endpoint); + } + + @Override + public udmi.schema.EndpointConfiguration fetchDownloadBlob(String registryId, String deviceId, + String blobKey) { + return getRegistryProvider(registryId, deviceId) + .fetchDownloadBlob(registryId, deviceId, blobKey); + } + /** * Sets the provider affinity for a given registry and device. * diff --git a/udmis/src/main/java/com/google/bos/udmi/service/access/IotAccessBase.java b/udmis/src/main/java/com/google/bos/udmi/service/access/IotAccessBase.java index deb758f3ef..5b68844afd 100644 --- a/udmis/src/main/java/com/google/bos/udmi/service/access/IotAccessBase.java +++ b/udmis/src/main/java/com/google/bos/udmi/service/access/IotAccessBase.java @@ -309,6 +309,45 @@ public void setProviderAffinity(String registryId, String deviceId, String provi registryBackoffClear(registryId, deviceId); } + /** + * Send an ephemeral download message directly to a device. + */ + public final void sendDownload(Envelope envelope, SubFolder folder, String message) { + String registryId = requireNonNull(envelope.deviceRegistryId, "registry not defined"); + String deviceId = requireNonNull(envelope.deviceId, "device not defined"); + debug("Sending download to %s/%s/%s", registryId, deviceId, folder); + sendDownloadBase(envelope, folder, message); + } + + @Override + public void handleBlobsetState(Envelope envelope, udmi.schema.BlobsetState blobsetState) { + if (blobsetState == null || blobsetState.blobs == null || blobsetState.blobs.isEmpty()) { + return; + } + String registryId = envelope.deviceRegistryId; + String deviceId = envelope.deviceId; + if (registryId == null || deviceId == null) { + return; + } + java.util.HashMap matchedBlobs = + new java.util.HashMap<>(); + blobsetState.blobs.forEach((key, blobState) -> { + if (blobState != null && blobState.phase == udmi.schema.BlobBlobsetConfig.BlobPhase.APPLY) { + udmi.schema.EndpointConfiguration endpoint = fetchDownloadBlob(registryId, deviceId, key); + if (endpoint != null) { + matchedBlobs.put(key, endpoint); + } + } + }); + if (!matchedBlobs.isEmpty()) { + udmi.schema.BlobsetDownload download = new udmi.schema.BlobsetDownload(); + download.version = UdmiServicePod.UDMI_VERSION; + download.timestamp = new java.util.Date(); + download.blobs = matchedBlobs; + sendDownload(envelope, SubFolder.BLOBSET, com.google.udmi.util.JsonUtil.stringify(download)); + } + } + /** * Update the cached registry regions with any incremental updates. */ diff --git a/udmis/src/main/java/com/google/bos/udmi/service/access/IotAccessProvider.java b/udmis/src/main/java/com/google/bos/udmi/service/access/IotAccessProvider.java index 21a7e4de3d..8c241c25dc 100644 --- a/udmis/src/main/java/com/google/bos/udmi/service/access/IotAccessProvider.java +++ b/udmis/src/main/java/com/google/bos/udmi/service/access/IotAccessProvider.java @@ -74,6 +74,21 @@ CloudModel modelDevice(String registryId, String deviceId, void sendCommandBase(Envelope envelope, SubFolder folder, String message); + default void sendDownloadBase(Envelope envelope, SubFolder folder, String message) { + } + + default void provisionDownloadBlob(String registryId, String deviceId, String blobKey, + udmi.schema.EndpointConfiguration endpoint) { + } + + default udmi.schema.EndpointConfiguration fetchDownloadBlob(String registryId, String deviceId, + String blobKey) { + return null; + } + + default void handleBlobsetState(Envelope envelope, udmi.schema.BlobsetState blobsetState) { + } + /** * Returns true if this provider supports registry and device management operations. */ diff --git a/udmis/src/main/java/com/google/bos/udmi/service/access/LocalIotAccessProvider.java b/udmis/src/main/java/com/google/bos/udmi/service/access/LocalIotAccessProvider.java index 4d670b7825..abfd2cf681 100644 --- a/udmis/src/main/java/com/google/bos/udmi/service/access/LocalIotAccessProvider.java +++ b/udmis/src/main/java/com/google/bos/udmi/service/access/LocalIotAccessProvider.java @@ -104,6 +104,13 @@ public void sendCommandBase(Envelope envelope, SubFolder folder, String message) format("%s/%s/%s:%s", envelope.deviceRegistryId, envelope.deviceId, folder, message)); } + @Override + public void sendDownloadBase(Envelope envelope, SubFolder folder, String message) { + sentCommands.add( + format("download:%s/%s/%s:%s", + envelope.deviceRegistryId, envelope.deviceId, folder, message)); + } + @TestOnly public void setFailureForTest() { failActivation = true; diff --git a/udmis/src/main/java/com/google/bos/udmi/service/access/ZanzaraIotAccessProvider.java b/udmis/src/main/java/com/google/bos/udmi/service/access/ZanzaraIotAccessProvider.java index ee406c6c80..59c5d4b38d 100644 --- a/udmis/src/main/java/com/google/bos/udmi/service/access/ZanzaraIotAccessProvider.java +++ b/udmis/src/main/java/com/google/bos/udmi/service/access/ZanzaraIotAccessProvider.java @@ -922,6 +922,48 @@ public void sendCommandBase(Envelope baseEnvelope, SubFolder folder, String mess } } + @Override + public void sendDownloadBase(Envelope baseEnvelope, SubFolder folder, String message) { + if (mqttPipe == null) { + warn("MQTT pipe not initialized, unable to send download"); + return; + } + try { + Envelope envelope = deepCopy(baseEnvelope); + envelope.subFolder = folder; + envelope.subType = SubType.DOWNLOAD; + envelope.source = IotProvider.ZANZARA.value(); + + Bundle bundle = new Bundle(envelope, MessageDispatcher.rawString(message)); + mqttPipe.publish(bundle); + debug("Published download to pipe for %s/%s/%s", + baseEnvelope.deviceRegistryId, baseEnvelope.deviceId, folder); + } catch (Exception e) { + error("Failed to send download for %s/%s: %s", + baseEnvelope.deviceRegistryId, baseEnvelope.deviceId, friendlyStackTrace(e)); + throw new RuntimeException("While sending download for " + + baseEnvelope.deviceRegistryId + "/" + baseEnvelope.deviceId, e); + } + } + + @Override + public void provisionDownloadBlob(String registryId, String deviceId, String blobKey, + EndpointConfiguration endpoint) { + requireNonNull(registryId, "registryId not defined"); + requireNonNull(deviceId, "deviceId not defined"); + requireNonNull(blobKey, "blobKey not defined"); + requireNonNull(endpoint, "endpoint not defined"); + registryDeviceRef(registryId, deviceId) + .put("download_blob_" + blobKey, stringifyTerse(endpoint)); + } + + @Override + public EndpointConfiguration fetchDownloadBlob(String registryId, String deviceId, + String blobKey) { + String json = registryDeviceRef(registryId, deviceId).get("download_blob_" + blobKey); + return json == null ? null : JsonUtil.fromString(EndpointConfiguration.class, json); + } + @Override public void shutdown() { ifNotNullThen(mqttPipe, pipe -> { diff --git a/udmis/src/main/java/com/google/bos/udmi/service/core/StateProcessor.java b/udmis/src/main/java/com/google/bos/udmi/service/core/StateProcessor.java index 2e6bddb183..3ee5f0d42b 100644 --- a/udmis/src/main/java/com/google/bos/udmi/service/core/StateProcessor.java +++ b/udmis/src/main/java/com/google/bos/udmi/service/core/StateProcessor.java @@ -63,6 +63,7 @@ protected SubType getExceptionSubType() { private void processStateUpdate(MessageContinuation continuation, Envelope envelope, StateUpdate message) { iotAccess.saveState(envelope.deviceRegistryId, envelope.deviceId, stringifyTerse(message)); + ifNotNullThen(message.blobset, blobset -> iotAccess.handleBlobsetState(envelope, blobset)); continuation.publish(message); String origTxnId = envelope.transactionId; AtomicInteger txnSuffix = new AtomicInteger(); diff --git a/udmis/src/main/java/com/google/bos/udmi/service/core/TargetProcessor.java b/udmis/src/main/java/com/google/bos/udmi/service/core/TargetProcessor.java index 04556a85c0..fb000d7c4b 100644 --- a/udmis/src/main/java/com/google/bos/udmi/service/core/TargetProcessor.java +++ b/udmis/src/main/java/com/google/bos/udmi/service/core/TargetProcessor.java @@ -68,6 +68,29 @@ protected void defaultHandler(Object defaultedMessage) { reflectMessage(envelope, message); } + /** + * Handle chunked discovery uploads arriving on the target message channel. + */ + @MessageHandler + public void discoveryUploadHandler(udmi.schema.DiscoveryUpload message) { + MessageContinuation continuation = getContinuation(message); + Envelope envelope = continuation.getEnvelope(); + defaultFields(message); + publish(message); + updateLastSeen(envelope); + com.google.bos.udmi.service.support.IotDataProvider dataProvider = + com.google.bos.udmi.service.pod.UdmiServicePod.maybeGetComponent( + UploadProcessor.DATABASE_COMPONENT); + if (dataProvider != null && envelope.deviceId != null && envelope.deviceRegistryId != null) { + UploadProcessor uploadProcessor = + com.google.bos.udmi.service.pod.UdmiServicePod.maybeGetComponent(UploadProcessor.class); + java.io.File outDir = uploadProcessor != null + ? uploadProcessor.getOutputDir() + : new java.io.File("/tmp/udmi_uploads"); + UploadProcessor.processDiscoveryUpload(envelope, message, dataProvider, outDir); + } + } + @Override protected SubType getExceptionSubType() { return SubType.EVENTS; diff --git a/udmis/src/main/java/com/google/bos/udmi/service/core/UploadProcessor.java b/udmis/src/main/java/com/google/bos/udmi/service/core/UploadProcessor.java new file mode 100644 index 0000000000..fdde1aa4c5 --- /dev/null +++ b/udmis/src/main/java/com/google/bos/udmi/service/core/UploadProcessor.java @@ -0,0 +1,200 @@ +package com.google.bos.udmi.service.core; + +import static com.google.udmi.util.GeneralUtils.ifNotNullThen; +import static java.lang.String.format; +import static java.util.Objects.requireNonNull; + +import com.google.bos.udmi.service.messaging.MessageContinuation; +import com.google.bos.udmi.service.pod.UdmiServicePod; +import com.google.bos.udmi.service.support.DataRef; +import com.google.bos.udmi.service.support.IotDataProvider; +import java.io.ByteArrayOutputStream; +import java.io.File; +import java.nio.file.Files; +import java.security.MessageDigest; +import java.util.Base64; +import java.util.HashMap; +import java.util.HexFormat; +import java.util.Map; +import java.util.Set; +import udmi.schema.DiscoveryUpload; +import udmi.schema.EndpointConfiguration; +import udmi.schema.Envelope; +import udmi.schema.Envelope.SubType; + +/** + * Processor for device-to-cloud chunked binary uploads over the {@code upload} channel. + * + *

Stages incoming chunks in the shared {@link IotDataProvider} (Etcd) so multiple UDMIS pods + * can receive chunks in any order and uses an atomic Compare-And-Swap election to ensure exactly + * one pod reassembles and verifies the complete payload once all chunks and the negative EOF + * boundary marker have arrived. + */ +@ComponentName("upload") +public class UploadProcessor extends ProcessorBase { + + public static final String DATABASE_COMPONENT = "database"; + public static final String CHUNK_KEY_PREFIX = "chunk_"; + public static final String EXPECTED_CHUNKS_KEY = "expected_chunks"; + public static final String EXPECTED_SHA256_KEY = "expected_sha256"; + public static final String STATUS_KEY = "status"; + public static final String STATUS_ASSEMBLING = "assembling"; + private static final String DEFAULT_UPLOAD_DIR = "/tmp/udmi_uploads"; + + private IotDataProvider database; + private File outputDir = new File(DEFAULT_UPLOAD_DIR); + + public UploadProcessor(EndpointConfiguration config) { + super(config); + } + + @Override + public void activate() { + database = UdmiServicePod.maybeGetComponent(DATABASE_COMPONENT); + super.activate(); + } + + @Override + protected SubType getExceptionSubType() { + return SubType.UPLOAD; + } + + public void setOutputDir(File outputDir) { + this.outputDir = requireNonNull(outputDir, "outputDir not defined"); + } + + public File getOutputDir() { + return outputDir; + } + + /** + * Handle discovery upload chunk messages on {@code upload/discovery}. + */ + @MessageHandler + public void discoveryUploadHandler(DiscoveryUpload message) { + MessageContinuation continuation = getContinuation(message); + Envelope envelope = continuation.getEnvelope(); + IotDataProvider dataProvider = database != null + ? database + : UdmiServicePod.maybeGetComponent(DATABASE_COMPONENT); + requireNonNull(dataProvider, "Missing database component for upload reassembly"); + processDiscoveryUpload(envelope, message, dataProvider, outputDir); + } + + /** + * Stage a discovery upload chunk or boundary marker in shared {@link IotDataProvider} and + * atomically reassemble the binary artifact once all chunks and the EOF marker are present. + */ + public static File processDiscoveryUpload( + Envelope envelope, + DiscoveryUpload message, + IotDataProvider dataProvider, + File baseOutputDir) { + String registryId = requireNonNull(envelope.deviceRegistryId, "missing deviceRegistryId"); + String deviceId = requireNonNull(envelope.deviceId, "missing deviceId"); + String sessionId = requireNonNull(message.session_id, "missing session_id"); + Integer eventNo = requireNonNull(message.event_no, "missing event_no"); + + String sanitizedSession = sessionId.replaceAll("[:/]", "_"); + DataRef uploadRef = dataProvider.ref() + .registry(registryId) + .device(deviceId) + .collection("upload_" + sanitizedSession); + + if (eventNo == 0) { + Map startPuts = new HashMap<>(); + startPuts.put("started", "true"); + ifNotNullThen(message.family, family -> startPuts.put("family", family)); + if (message.total_chunks != null && message.total_chunks > 0) { + startPuts.put(EXPECTED_CHUNKS_KEY, String.valueOf(message.total_chunks)); + } + uploadRef.update(startPuts, null); + return null; + } else if (eventNo > 0) { + if (message.data == null || message.data.isEmpty()) { + throw new IllegalArgumentException( + format("Upload chunk event_no %d for session %s missing data payload", + eventNo, sessionId)); + } + Map chunkPuts = new HashMap<>(); + chunkPuts.put(CHUNK_KEY_PREFIX + eventNo, message.data); + if (message.total_chunks != null && message.total_chunks > 0) { + chunkPuts.put(EXPECTED_CHUNKS_KEY, String.valueOf(message.total_chunks)); + } + uploadRef.update(chunkPuts, null); + } else { + int expectedChunks = -(eventNo + 1); + if (expectedChunks <= 0) { + throw new IllegalArgumentException( + format("Invalid terminal event_no %d for session %s", eventNo, sessionId)); + } + if (message.sha256 == null || message.sha256.isEmpty()) { + throw new IllegalArgumentException( + format("Terminal upload marker event_no %d for session %s missing sha256", + eventNo, sessionId)); + } + Map eofPuts = new HashMap<>(); + eofPuts.put(EXPECTED_CHUNKS_KEY, String.valueOf(expectedChunks)); + eofPuts.put(EXPECTED_SHA256_KEY, message.sha256); + uploadRef.update(eofPuts, null); + } + + Map entries = uploadRef.entries(); + String expectedChunksStr = entries.get(EXPECTED_CHUNKS_KEY); + String expectedSha256 = entries.get(EXPECTED_SHA256_KEY); + if (expectedChunksStr == null || expectedSha256 == null) { + return null; + } + + int totalChunks = Integer.parseInt(expectedChunksStr); + for (int i = 1; i <= totalChunks; i++) { + if (!entries.containsKey(CHUNK_KEY_PREFIX + i)) { + return null; + } + } + + if (!uploadRef.updateIfMatch(STATUS_KEY, null, Map.of(STATUS_KEY, STATUS_ASSEMBLING), null)) { + return null; + } + + Set cleanupKeys = uploadRef.entries().keySet(); + try { + ByteArrayOutputStream assembled = new ByteArrayOutputStream(); + for (int i = 1; i <= totalChunks; i++) { + String chunkBase64 = entries.get(CHUNK_KEY_PREFIX + i); + byte[] decoded = Base64.getDecoder().decode(chunkBase64); + assembled.write(decoded); + } + byte[] payloadBytes = assembled.toByteArray(); + String actualSha256 = computeSha256Hex(payloadBytes); + if (!expectedSha256.equalsIgnoreCase(actualSha256)) { + throw new IllegalStateException(format( + "SHA-256 mismatch for upload session %s: expected %s, got %s", + sessionId, expectedSha256, actualSha256)); + } + + File deviceOutputDir = new File(new File(baseOutputDir, registryId), deviceId); + Files.createDirectories(deviceOutputDir.toPath()); + File outputFile = new File(deviceOutputDir, sanitizedSession + ".pcap"); + Files.write(outputFile.toPath(), payloadBytes); + return outputFile; + } catch (RuntimeException e) { + throw e; + } catch (Exception e) { + throw new RuntimeException( + format("While reassembling upload session %s for %s/%s", + sessionId, registryId, deviceId), e); + } finally { + uploadRef.update(null, cleanupKeys); + } + } + + static String computeSha256Hex(byte[] data) { + try { + MessageDigest digest = MessageDigest.getInstance("SHA-256"); + return HexFormat.of().formatHex(digest.digest(data)); + } catch (Exception e) { + throw new RuntimeException("Unable to compute SHA-256 digest", e); + } + } +} diff --git a/udmis/src/main/java/com/google/bos/udmi/service/messaging/impl/SimpleMqttPipe.java b/udmis/src/main/java/com/google/bos/udmi/service/messaging/impl/SimpleMqttPipe.java index d65f96d5dc..e64ad177f7 100644 --- a/udmis/src/main/java/com/google/bos/udmi/service/messaging/impl/SimpleMqttPipe.java +++ b/udmis/src/main/java/com/google/bos/udmi/service/messaging/impl/SimpleMqttPipe.java @@ -349,10 +349,11 @@ private String makeBrokerUrl(EndpointConfiguration endpoint) { return format(BROKER_URL_FORMAT, transport, endpoint.hostname, port); } - private MqttMessage makeMqttMessage(Bundle bundle) { + static MqttMessage makeMqttMessage(Bundle bundle) { MqttMessage message = new MqttMessage(); message.setPayload(bundle.sendBytes()); message.setRetained(shouldRetainMessage(bundle)); + message.setQos(getMessageQos(bundle)); return message; } @@ -404,8 +405,12 @@ private String extractTransactionId(String payload) { } } - private boolean shouldRetainMessage(Bundle bundle) { - return bundle.envelope.subType == SubType.CONFIG; + static int getMessageQos(Bundle bundle) { + return (bundle.envelope != null && bundle.envelope.subType == SubType.DOWNLOAD) ? 0 : 1; + } + + static boolean shouldRetainMessage(Bundle bundle) { + return bundle.envelope != null && bundle.envelope.subType == SubType.CONFIG; } private void subscribeToMessages() { @@ -419,7 +424,8 @@ private void subscribeToMessages() { boolean connected = mqttClient.isConnected(); trace("Subscribing %s, active=%s connected=%s", clientId, isActive(), connected); if (isActive() && connected) { - mqttClient.subscribe(subscribeTopic); + int qos = recvId.startsWith("download") || subscribeTopic.contains("/download") ? 0 : 1; + mqttClient.subscribe(subscribeTopic, qos); info("Subscribed %s to topic %s", clientId, subscribeTopic); } } diff --git a/udmis/src/main/java/com/google/bos/udmi/service/pod/UdmiServicePod.java b/udmis/src/main/java/com/google/bos/udmi/service/pod/UdmiServicePod.java index ec443eb507..6053c39d1f 100644 --- a/udmis/src/main/java/com/google/bos/udmi/service/pod/UdmiServicePod.java +++ b/udmis/src/main/java/com/google/bos/udmi/service/pod/UdmiServicePod.java @@ -24,6 +24,7 @@ import com.google.bos.udmi.service.core.ReflectProcessor; import com.google.bos.udmi.service.core.StateProcessor; import com.google.bos.udmi.service.core.TargetProcessor; +import com.google.bos.udmi.service.core.UploadProcessor; import com.google.bos.udmi.service.core.UufiProcessor; import com.google.bos.udmi.service.support.IotDataProvider; import com.google.common.collect.ImmutableSet; @@ -61,7 +62,7 @@ public class UdmiServicePod extends ContainerBase { private static final Map COMPONENT_MAP = new ConcurrentHashMap<>(); private static final Set> PROCESSOR_CLASSES = ImmutableSet.of( TargetProcessor.class, ReflectProcessor.class, StateProcessor.class, ControlProcessor.class, - BitboxAdapter.class, DistributorPipe.class, UufiProcessor.class); + BitboxAdapter.class, DistributorPipe.class, UufiProcessor.class, UploadProcessor.class); private static final Map> PROCESSORS = new HashMap<>(); static { diff --git a/udmis/src/main/java/com/google/bos/udmi/service/support/MosquittoBroker.java b/udmis/src/main/java/com/google/bos/udmi/service/support/MosquittoBroker.java index 880324dbcc..369365aa18 100644 --- a/udmis/src/main/java/com/google/bos/udmi/service/support/MosquittoBroker.java +++ b/udmis/src/main/java/com/google/bos/udmi/service/support/MosquittoBroker.java @@ -247,15 +247,19 @@ private CompletableFuture provisionClient(String clientId, String clientPa CompletableFuture a3 = addRoleAcl(roleName, "subscribePattern", clientId + "/errors", true); CompletableFuture a4 = - addRoleAcl(roleName, "publishClientSend", clientId + "/events/#", true); + addRoleAcl(roleName, "subscribePattern", clientId + "/download/#", true); CompletableFuture a5 = + addRoleAcl(roleName, "publishClientSend", clientId + "/events/#", true); + CompletableFuture a6 = + addRoleAcl(roleName, "publishClientSend", clientId + "/upload/#", true); + CompletableFuture a7 = addRoleAcl(roleName, "publishClientSend", clientId + "/state", true); if (isReflectRegistry(clientId)) { - CompletableFuture a6 = + CompletableFuture a8 = addRoleAcl(roleName, "publishClientSend", clientId + "/reflect", true); - return CompletableFuture.allOf(a1, a2, a3, a4, a5, a6); + return CompletableFuture.allOf(a1, a2, a3, a4, a5, a6, a7, a8); } - return CompletableFuture.allOf(a1, a2, a3, a4, a5); + return CompletableFuture.allOf(a1, a2, a3, a4, a5, a6, a7); }).thenRun(() -> info("Device %s registered correctly.", clientId)); } } @@ -477,19 +481,23 @@ public CompletableFuture bindGateway(String gatewayId, String deviceId) { CompletableFuture a3 = addRoleAcl(roleName, "subscribePattern", deviceId + "/errors", true); CompletableFuture a4 = - addRoleAcl(roleName, "publishClientSend", deviceId + "/events/#", true); + addRoleAcl(roleName, "subscribePattern", deviceId + "/download/#", true); CompletableFuture a5 = - addRoleAcl(roleName, "publishClientSend", deviceId + "/state", true); + addRoleAcl(roleName, "publishClientSend", deviceId + "/events/#", true); CompletableFuture a6 = + addRoleAcl(roleName, "publishClientSend", deviceId + "/upload/#", true); + CompletableFuture a7 = + addRoleAcl(roleName, "publishClientSend", deviceId + "/state", true); + CompletableFuture a8 = addRoleAcl(roleName, "publishClientSend", deviceId + "/attach", true); if (isReflectRegistry(deviceId)) { - CompletableFuture a7 = + CompletableFuture a9 = addRoleAcl(roleName, "publishClientSend", deviceId + "/reflect", true); - return CompletableFuture.allOf(a1, a2, a3, a4, a5, a6, a7); + return CompletableFuture.allOf(a1, a2, a3, a4, a5, a6, a7, a8, a9); } - return CompletableFuture.allOf(a1, a2, a3, a4, a5, a6); + return CompletableFuture.allOf(a1, a2, a3, a4, a5, a6, a7, a8); } @Override @@ -508,19 +516,23 @@ public CompletableFuture unbindGateway(String gatewayId, String deviceId) CompletableFuture a3 = removeRoleAcl(roleName, "subscribePattern", deviceId + "/errors"); CompletableFuture a4 = - removeRoleAcl(roleName, "publishClientSend", deviceId + "/events/#"); + removeRoleAcl(roleName, "subscribePattern", deviceId + "/download/#"); CompletableFuture a5 = - removeRoleAcl(roleName, "publishClientSend", deviceId + "/state"); + removeRoleAcl(roleName, "publishClientSend", deviceId + "/events/#"); CompletableFuture a6 = + removeRoleAcl(roleName, "publishClientSend", deviceId + "/upload/#"); + CompletableFuture a7 = + removeRoleAcl(roleName, "publishClientSend", deviceId + "/state"); + CompletableFuture a8 = removeRoleAcl(roleName, "publishClientSend", deviceId + "/attach"); if (isReflectRegistry(deviceId)) { - CompletableFuture a7 = + CompletableFuture a9 = removeRoleAcl(roleName, "publishClientSend", deviceId + "/reflect"); - return CompletableFuture.allOf(a1, a2, a3, a4, a5, a6, a7); + return CompletableFuture.allOf(a1, a2, a3, a4, a5, a6, a7, a8, a9); } - return CompletableFuture.allOf(a1, a2, a3, a4, a5, a6); + return CompletableFuture.allOf(a1, a2, a3, a4, a5, a6, a7, a8); } private CompletableFuture removeRoleAcl(String roleName, String type, String pattern) { diff --git a/udmis/src/test/java/com/google/bos/udmi/service/access/ZanzaraIotAccessProviderTest.java b/udmis/src/test/java/com/google/bos/udmi/service/access/ZanzaraIotAccessProviderTest.java index 032b7a78f8..cdb8b4a7c1 100644 --- a/udmis/src/test/java/com/google/bos/udmi/service/access/ZanzaraIotAccessProviderTest.java +++ b/udmis/src/test/java/com/google/bos/udmi/service/access/ZanzaraIotAccessProviderTest.java @@ -354,6 +354,43 @@ void testModelDeviceAddsRegistryToGetRegistries() { assertTrue(registries.contains(TEST_REGISTRY)); } + @Test + void testHandleBlobsetStatePushesDownload() throws Exception { + com.google.bos.udmi.service.messaging.impl.SimpleMqttPipe mockPipe = + mock(com.google.bos.udmi.service.messaging.impl.SimpleMqttPipe.class); + Field pipeField = ZanzaraIotAccessProvider.class.getDeclaredField("mqttPipe"); + pipeField.setAccessible(true); + pipeField.set(provider, mockPipe); + + udmi.schema.EndpointConfiguration provisionedEndpoint = new udmi.schema.EndpointConfiguration(); + provisionedEndpoint.protocol = udmi.schema.EndpointConfiguration.Protocol.MQTT; + provisionedEndpoint.hostname = "mqtt.example.com"; + provisionedEndpoint.client_id = TEST_DEVICE; + provider.provisionDownloadBlob( + TEST_REGISTRY, TEST_DEVICE, "_iot_endpoint_config", provisionedEndpoint); + + udmi.schema.BlobsetState blobsetState = new udmi.schema.BlobsetState(); + blobsetState.blobs = new HashMap<>(); + udmi.schema.BlobBlobsetState blobState = new udmi.schema.BlobBlobsetState(); + blobState.phase = udmi.schema.BlobBlobsetConfig.BlobPhase.APPLY; + blobsetState.blobs.put("_iot_endpoint_config", blobState); + + udmi.schema.Envelope env = new udmi.schema.Envelope(); + env.deviceRegistryId = TEST_REGISTRY; + env.deviceId = TEST_DEVICE; + + provider.handleBlobsetState(env, blobsetState); + + org.mockito.ArgumentCaptor + bundleCaptor = org.mockito.ArgumentCaptor.forClass( + com.google.bos.udmi.service.messaging.impl.MessageBase.Bundle.class); + verify(mockPipe, times(1)).publish(bundleCaptor.capture()); + com.google.bos.udmi.service.messaging.impl.MessageBase.Bundle published = + bundleCaptor.getValue(); + assertEquals(udmi.schema.Envelope.SubType.DOWNLOAD, published.envelope.subType); + assertEquals(udmi.schema.Envelope.SubFolder.BLOBSET, published.envelope.subFolder); + } + class FakeDataRef extends DataRef { private final Map data; diff --git a/udmis/src/test/java/com/google/bos/udmi/service/core/StateProcessorTest.java b/udmis/src/test/java/com/google/bos/udmi/service/core/StateProcessorTest.java index a13568ed52..ae40d650ea 100644 --- a/udmis/src/test/java/com/google/bos/udmi/service/core/StateProcessorTest.java +++ b/udmis/src/test/java/com/google/bos/udmi/service/core/StateProcessorTest.java @@ -228,4 +228,20 @@ public void processMessagePayloadLogging() { System.setOut(originalOut); } } + + @Test + public void blobsetApplyTriggersDownload() { + initializeTestInstance(); + State stateMessage = getTestStateMessage(false, false); + stateMessage.blobset = new udmi.schema.BlobsetState(); + stateMessage.blobset.blobs = new java.util.HashMap<>(); + udmi.schema.BlobBlobsetState blobState = new udmi.schema.BlobBlobsetState(); + blobState.phase = udmi.schema.BlobBlobsetConfig.BlobPhase.APPLY; + stateMessage.blobset.blobs.put("_iot_endpoint_config", blobState); + + getReverseDispatcher().publish(new Bundle(getTestStateEnvelope(), stateMessage)); + terminateAndWait(); + + verify(provider, times(1)).handleBlobsetState(any(Envelope.class), any()); + } } diff --git a/udmis/src/test/java/com/google/bos/udmi/service/core/UploadProcessorTest.java b/udmis/src/test/java/com/google/bos/udmi/service/core/UploadProcessorTest.java new file mode 100644 index 0000000000..20f8968d9b --- /dev/null +++ b/udmis/src/test/java/com/google/bos/udmi/service/core/UploadProcessorTest.java @@ -0,0 +1,198 @@ +package com.google.bos.udmi.service.core; + +import static org.junit.jupiter.api.Assertions.assertArrayEquals; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +import com.google.bos.udmi.service.pod.UdmiServicePod; +import com.google.bos.udmi.service.support.DataRef; +import com.google.bos.udmi.service.support.IotDataProvider; +import java.io.File; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.util.Base64; +import java.util.HashMap; +import java.util.Map; +import java.util.Set; +import java.util.concurrent.ConcurrentHashMap; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; +import udmi.schema.DiscoveryUpload; +import udmi.schema.Envelope; + +class UploadProcessorTest { + + @TempDir + File tempDir; + + private Map sharedStore; + private IotDataProvider dataProvider; + private Envelope envelope; + + @BeforeEach + void setUp() { + UdmiServicePod.resetForTest(); + sharedStore = new ConcurrentHashMap<>(); + dataProvider = mock(IotDataProvider.class); + when(dataProvider.ref()).thenAnswer(inv -> new SharedMemoryDataRef(sharedStore)); + envelope = new Envelope(); + envelope.deviceRegistryId = "test-reg"; + envelope.deviceId = "SNP-1"; + } + + @AfterEach + void tearDown() { + UdmiServicePod.resetForTest(); + } + + @Test + void testOutOfOrderMultiPodReassemblyAndSha256Verification() throws Exception { + final byte[] chunk1Bytes = "PCAP-HEADER-AND-FIRST-PACKET-".getBytes(StandardCharsets.UTF_8); + final byte[] chunk2Bytes = "SECOND-PACKET-AND-FOOTER".getBytes(StandardCharsets.UTF_8); + final byte[] fullPayload = "PCAP-HEADER-AND-FIRST-PACKET-SECOND-PACKET-AND-FOOTER" + .getBytes(StandardCharsets.UTF_8); + final String expectedSha256 = UploadProcessor.computeSha256Hex(fullPayload); + final String sessionId = "trace-bacnet-2026-09-27T12:00:00Z"; + + // Pod 1 receives start marker (event_no = 0) + DiscoveryUpload startMsg = new DiscoveryUpload(); + startMsg.session_id = sessionId; + startMsg.event_no = 0; + startMsg.family = "bacnet"; + startMsg.total_chunks = 2; + assertNull(UploadProcessor.processDiscoveryUpload(envelope, startMsg, dataProvider, tempDir)); + + // Pod 2 receives chunk 2 out-of-order (event_no = 2) + DiscoveryUpload chunk2Msg = new DiscoveryUpload(); + chunk2Msg.session_id = sessionId; + chunk2Msg.event_no = 2; + chunk2Msg.chunk_index = 1; + chunk2Msg.total_chunks = 2; + chunk2Msg.data = Base64.getEncoder().encodeToString(chunk2Bytes); + assertNull(UploadProcessor.processDiscoveryUpload(envelope, chunk2Msg, dataProvider, tempDir)); + + // Pod 3 receives terminal EOF marker (event_no = -3) before chunk 1 arrives + DiscoveryUpload eofMsg = new DiscoveryUpload(); + eofMsg.session_id = sessionId; + eofMsg.event_no = -3; + eofMsg.total_chunks = 2; + eofMsg.sha256 = expectedSha256; + assertNull(UploadProcessor.processDiscoveryUpload(envelope, eofMsg, dataProvider, tempDir)); + + // Pod 1 receives chunk 1 (event_no = 1), completing the set and winning CAS election + DiscoveryUpload chunk1Msg = new DiscoveryUpload(); + chunk1Msg.session_id = sessionId; + chunk1Msg.event_no = 1; + chunk1Msg.chunk_index = 0; + chunk1Msg.total_chunks = 2; + chunk1Msg.data = Base64.getEncoder().encodeToString(chunk1Bytes); + File reassembled = + UploadProcessor.processDiscoveryUpload(envelope, chunk1Msg, dataProvider, tempDir); + + assertNotNull(reassembled); + assertTrue(reassembled.exists()); + assertArrayEquals(fullPayload, Files.readAllBytes(reassembled.toPath())); + assertTrue(sharedStore.isEmpty(), "Staged DataRef entries must be cleaned up after reassembly"); + } + + @Test + void testSha256MismatchFailsFastAndCleansUp() { + byte[] chunk1Bytes = "corrupted-chunk".getBytes(StandardCharsets.UTF_8); + String sessionId = "trace-corrupted"; + + DiscoveryUpload chunk1Msg = new DiscoveryUpload(); + chunk1Msg.session_id = sessionId; + chunk1Msg.event_no = 1; + chunk1Msg.data = Base64.getEncoder().encodeToString(chunk1Bytes); + assertNull(UploadProcessor.processDiscoveryUpload(envelope, chunk1Msg, dataProvider, tempDir)); + + DiscoveryUpload eofMsg = new DiscoveryUpload(); + eofMsg.session_id = sessionId; + eofMsg.event_no = -2; + eofMsg.sha256 = "0000000000000000000000000000000000000000000000000000000000000000"; + + IllegalStateException thrown = assertThrows(IllegalStateException.class, + () -> UploadProcessor.processDiscoveryUpload(envelope, eofMsg, dataProvider, tempDir)); + assertTrue(thrown.getMessage().contains("SHA-256 mismatch")); + assertEquals(0, sharedStore.size(), "Staged DataRef entries must be cleaned up on failure"); + } + + private static class SharedMemoryDataRef extends DataRef { + private final Map data; + + SharedMemoryDataRef(Map data) { + this.data = data; + } + + private String getKeyPath(String key) { + return (registryId != null ? "r/" + registryId : "") + + (deviceId != null ? "/d/" + deviceId : "") + + (collection != null ? "/c/" + collection : "") + + ":" + + key; + } + + @Override + public void delete(String key) { + data.remove(getKeyPath(key)); + } + + @Override + public Map entries() { + String prefix = getKeyPath(""); + Map res = new HashMap<>(); + for (Map.Entry entry : data.entrySet()) { + if (entry.getKey().startsWith(prefix)) { + res.put(entry.getKey().substring(prefix.length()), entry.getValue()); + } + } + return res; + } + + @Override + public String get(String key) { + return data.get(getKeyPath(key)); + } + + @Override + public AutoCloseable lock() { + return () -> {}; + } + + @Override + public void put(String key, String value) { + data.put(getKeyPath(key), value); + } + + @Override + public void update(Map puts, Set deletes) { + if (puts != null) { + puts.forEach(this::put); + } + if (deletes != null) { + deletes.forEach(this::delete); + } + } + + @Override + public boolean updateIfMatch(String matchKey, String expectedValue, Map puts, + Set deletes) { + synchronized (data) { + String current = get(matchKey); + if ((expectedValue == null && current == null) + || (expectedValue != null && expectedValue.equals(current))) { + update(puts, deletes); + return true; + } + return false; + } + } + } +} diff --git a/udmis/src/test/java/com/google/bos/udmi/service/messaging/impl/SimpleMqttPipeTest.java b/udmis/src/test/java/com/google/bos/udmi/service/messaging/impl/SimpleMqttPipeTest.java index 3f714fcd4f..fc2a4eae56 100644 --- a/udmis/src/test/java/com/google/bos/udmi/service/messaging/impl/SimpleMqttPipeTest.java +++ b/udmis/src/test/java/com/google/bos/udmi/service/messaging/impl/SimpleMqttPipeTest.java @@ -112,5 +112,39 @@ public void staticEnvelopeTopic() { assertEquals("events", legacyPointset.get(SUBTYPE_PROPERTY_KEY)); assertEquals("pointset", legacyPointset.get(SUBFOLDER_PROPERTY_KEY)); assertEquals(3, legacyPointset.keySet().size()); + + Map discoveryUpload = parseEnvelopeTopic("/r/reg/d/dev/upload/discovery"); + assertEquals("reg", discoveryUpload.get(REGISTRY_ID_PROPERTY_KEY)); + assertEquals("dev", discoveryUpload.get(DEVICE_ID_KEY)); + assertEquals("upload", discoveryUpload.get(SUBTYPE_PROPERTY_KEY)); + assertEquals("discovery", discoveryUpload.get(SUBFOLDER_PROPERTY_KEY)); + + Map blobsetDownload = parseEnvelopeTopic("/r/reg/d/dev/download/blobset"); + assertEquals("reg", blobsetDownload.get(REGISTRY_ID_PROPERTY_KEY)); + assertEquals("dev", blobsetDownload.get(DEVICE_ID_KEY)); + assertEquals("download", blobsetDownload.get(SUBTYPE_PROPERTY_KEY)); + assertEquals("blobset", blobsetDownload.get(SUBFOLDER_PROPERTY_KEY)); + } + + @Test + public void downloadMessageQosAndRetain() { + MessageBase.Bundle downloadBundle = new MessageBase.Bundle(); + downloadBundle.envelope = new udmi.schema.Envelope(); + downloadBundle.envelope.subType = udmi.schema.Envelope.SubType.DOWNLOAD; + downloadBundle.envelope.subFolder = udmi.schema.Envelope.SubFolder.BLOBSET; + downloadBundle.payload = "{}"; + org.eclipse.paho.client.mqttv3.MqttMessage downloadMsg = + SimpleMqttPipe.makeMqttMessage(downloadBundle); + assertEquals(0, downloadMsg.getQos()); + assertEquals(false, downloadMsg.isRetained()); + + MessageBase.Bundle configBundle = new MessageBase.Bundle(); + configBundle.envelope = new udmi.schema.Envelope(); + configBundle.envelope.subType = udmi.schema.Envelope.SubType.CONFIG; + configBundle.payload = "{}"; + org.eclipse.paho.client.mqttv3.MqttMessage configMsg = + SimpleMqttPipe.makeMqttMessage(configBundle); + assertEquals(1, configMsg.getQos()); + assertEquals(true, configMsg.isRetained()); } } \ No newline at end of file From a8572046beac60f2c998bbed01c5f510b1246d0e Mon Sep 17 00:00:00 2001 From: Khyati Mahendru Date: Mon, 19 Oct 2026 08:40:09 +0000 Subject: [PATCH 2/2] Simplify upload/download schemas, unify butler reassembly, and add e2e sequence tests --- .gencode_hash.txt | 33 +- butler/bin/butler | 15 +- butler/src/dispatcher.py | 3 + butler/src/handlers/discovery.py | 2 +- butler/src/reassemble.py | 152 + butler/src/service.py | 2 +- butler/tests/test_dispatcher.py | 50 + butler/tests/test_handlers.py | 2 + clientlib/python/src/udmi/core/device.py | 112 +- .../src/udmi/core/managers/gateway_manager.py | 54 +- .../src/udmi/core/managers/system_manager.py | 2 +- .../core/managers/test_gateway_manager.py | 19 +- clientlib/python/tests/core/test_device.py | 220 +- docs/specs/blob_updates.md | 15 +- docs/specs/discovery.md | 6 +- docs/tools/spotter.md | 8 +- edge/spotter/GEMINI.md | 2 +- edge/spotter/README.md | 9 +- edge/spotter/bin/reassemble_pcap | 165 - edge/spotter/bin/test_pcap | 9 +- edge/spotter/src/manager/discovery.py | 12 +- edge/spotter/tests/test_agent.py | 11 +- etc/sequencer.out | 17 +- gencode/docs/config.html | 2 +- gencode/docs/configuration_endpoint.html | 232 - gencode/docs/configuration_execution.html | 534 -- gencode/docs/configuration_pod.html | 4483 +++++------------ gencode/docs/configuration_pubber.html | 267 - gencode/docs/download_blobset.html | 302 -- gencode/docs/persistent_device.html | 267 - gencode/docs/upload_discovery.html | 108 +- gencode/java/udmi/schema/Auth_provider.java | 5 +- gencode/java/udmi/schema/BlobsetConfig.java | 4 +- gencode/java/udmi/schema/DiscoveryUpload.java | 29 +- gencode/java/udmi/schema/Jwt.java | 11 +- gencode/java/udmi/schema/Mtls.java | 55 - gencode/presentation/presentation.json | 42 - gencode/python/udmi/schema/common.py | 2 - .../udmi/schema/configuration_endpoint.py | 9 - .../python/udmi/schema/upload_discovery.py | 3 - misc/bambi/presentation.gld.json | 42 - .../udmi/lib/client/host/PublisherHost.java | 41 +- .../lib/client/manager/DiscoveryManager.java | 65 +- .../src/test/java/daq/pubber/PubberTest.java | 65 + schema/common.json | 4 +- schema/configuration_endpoint.json | 38 - schema/upload_discovery.json | 17 +- tests/schemas/download_blobset/endpoint.json | 12 +- tests/schemas/upload_discovery/chunk.json | 3 - tests/schemas/upload_discovery/eof.json | 2 - udmis/etc/local_pod.json | 4 + .../access/ZanzaraIotAccessProvider.java | 1 + .../bos/udmi/service/core/ProcessorBase.java | 22 + .../udmi/service/core/ReflectProcessor.java | 3 + .../udmi/service/core/TargetProcessor.java | 27 +- .../udmi/service/core/UploadProcessor.java | 95 +- .../bos/udmi/service/core/UufiProcessor.java | 3 + .../bos/udmi/service/pod/UdmiServicePod.java | 1 + .../access/ZanzaraIotAccessProviderTest.java | 2 + .../service/core/ReflectProcessorTest.java | 25 + .../service/core/UploadProcessorTest.java | 38 +- .../udmi/service/core/UufiProcessorTest.java | 37 + .../daq/mqtt/sequencer/SequenceBase.java | 19 +- .../sequencer/sequences/BlobsetSequences.java | 39 +- .../sequences/DiscoverySequences.java | 73 + .../google/daq/mqtt/validator/Validator.java | 6 +- 66 files changed, 2295 insertions(+), 5664 deletions(-) create mode 100644 butler/src/reassemble.py delete mode 100755 edge/spotter/bin/reassemble_pcap delete mode 100644 gencode/java/udmi/schema/Mtls.java diff --git a/.gencode_hash.txt b/.gencode_hash.txt index d453d1bb02..9361e966b4 100644 --- a/.gencode_hash.txt +++ b/.gencode_hash.txt @@ -1,12 +1,12 @@ 23553972c4b777768f0f70fc0f89a0ec81f580b64031169a96379ea22bb95e79 gencode/docs/building_config_entity.html c21266e8de23b6e8acfac16a177db782375bf71df470c343c3f8eb8b822ef56a gencode/docs/commands_discovery.html -674ee4d2d37736f64c92c495568039f0d8b910075f37b66a03a55700332c1896 gencode/docs/config.html -9c73adc77b58e4ebb548d5392393ffb00523f76eaa6eee0618cb51b14f482215 gencode/docs/configuration_endpoint.html -fc091dc5b717ff0c9e8dc0a2c1eb776d5619868d0eeed68582a396c6ad2eaaef gencode/docs/configuration_execution.html -27f25b785f7d8b6e873ba907c4841ad0d493d40235e577b8c966b13b3712954f gencode/docs/configuration_pod.html -21b713fb487fcfad949019cd952116cc15d89c1424df18c39b217712c69768a2 gencode/docs/configuration_pubber.html +8aa5b5f48f97771deaf5585f8ee74c605c2a43568af04996ff8439843274cce9 gencode/docs/config.html +0c8f1fcfbf6de18a627f2de780ad13709ca33d52b0f4610e60e896d0856b652d gencode/docs/configuration_endpoint.html +196eed8a05e15293b43848a15a869a2fc2ac4e8a5129668c6d05590e8f7189a0 gencode/docs/configuration_execution.html +26391571e63a0d1d657e100b6aaccc695e3f041161f584ff7ed2943882d3bbba gencode/docs/configuration_pod.html +c26961bb7626fccd2ed4eec07a2d391b606b7c6c5680b7a369266f9159f68a85 gencode/docs/configuration_pubber.html 1057fa40fb7a31a23bb2773d21c38cf4590a935bd8b5ea4218e695c6204f5dd9 gencode/docs/data_template.html -c89d98aebe040442e619d0374674604c1c04c093d1ba33d751de0a43eda59c7c gencode/docs/download_blobset.html +86ae4ba48ebec9d368b9ad2c17d583b007bcf9082f4e511903f756f5ebadb9e8 gencode/docs/download_blobset.html 1d5294b4a1fea6555e23144e594523e4793c795069b6880ce607ad09a6015581 gencode/docs/equipment_translation.html 5fe5b52ca610e4c52bfe81bba4841d37fe243221a5f61ae719c9294f00a817aa gencode/docs/events.html 70e57ad6ef39330d958727ebf9dcd61ef6ea30e4c8653eac412bf1867fdb3a70 gencode/docs/events_alarmset.html @@ -18,7 +18,7 @@ baf59590b1a71a49e59d8f499ae5112a1b7a555294dbd72bc821203aecceefa3 gencode/docs/e 8d77357337531f7df7b88c332d9cac934723095c5be45ca410e31c9d1f665ee1 gencode/docs/metadata.html 925dd4b57d66b0a6126d0ca5521e00bd40fc7dd2400976c3d522421607186360 gencode/docs/monitoring.html 64d21cf916e20566171cf9d723e59c297b576bcba89e2a73aec2b018398cc7da gencode/docs/mosquitto_client_response.html -59e8644f3fd4fbba4e358e08e26fdf832dc4f7ed7e9ece4b409e3c9bd71176a3 gencode/docs/persistent_device.html +834e05bb872630fa2e01fe106294c53c9afaeea9718803872cbaf5e615d96600 gencode/docs/persistent_device.html e11595fd11477947a27461f8ef4fb6facb5f60e2abd6212193f7581ab123ff84 gencode/docs/properties.html 75d90e80269db99cb06199467c6c2483397370e15ac0392241e7e05ddf21852c gencode/docs/query_cloud.html 15248b985f2edfb19c56cd18d5e5115beba0154c8e2244c2accce646a3a9cc3f gencode/docs/readme.md @@ -28,7 +28,7 @@ e11595fd11477947a27461f8ef4fb6facb5f60e2abd6212193f7581ab123ff84 gencode/docs/p 7a1bbe014c739078d354ec484147325e1d0bd19468f275a53da38add43ebe089 gencode/docs/site_metadata.html 0993d21a1a0ae485bdb2f1794f279d13f782e841d07832f795624cc9f158ea93 gencode/docs/state.html 275171b2329d64d497fa54f8877656920be7c4f79bc90f67a0ff1bae6700399b gencode/docs/state_validation.html -7fc32021b42659fea2e69557b4f2a5078b9bb388729dea9b0c9cd96b01eae37a gencode/docs/upload_discovery.html +8af14f0488d5c5cfc1d0eb1b00c9b06f80e459ea13b83615ad89ab9e5dde2e99 gencode/docs/upload_discovery.html 46347b31bc2a56bc74af9bb702ad40c7823887d5e9b1427903a4892cde3cd169 gencode/java/udmi/schema/AlarmAlarmsetConfig.java 520b12eb8cec4f043f81f898c4be12def09b7de138533922e17c038415fb8902 gencode/java/udmi/schema/AlarmAlarmsetEvents.java 0740aeb4462739e9be7e9c846ebc3f3e6f45f3bb752327c681aa013fbd5c2711 gencode/java/udmi/schema/AlarmAlarmsetModel.java @@ -39,13 +39,13 @@ e11595fd11477947a27461f8ef4fb6facb5f60e2abd6212193f7581ab123ff84 gencode/docs/p 2d01271a645b6c8e07f0539c2f73ec2368e7d6125106c7338b3435811fd53f01 gencode/java/udmi/schema/AlarmsetState.java f7a4eb20ea0c074a10e884c95274f4b4284667e46b2049d12db53e4ec5ba1424 gencode/java/udmi/schema/Area.java ceab59904166bc6dcc12de0b78635ed0136ef4e2d5248284df0ec78ece487d83 gencode/java/udmi/schema/Asset.java -20dcabcfd058eb666b4ca00e901672293d620b52e9cb1f9d29a544da4e9e5270 gencode/java/udmi/schema/Auth_provider.java +53851d3d130fd3d5233c7265cfa83a7ff5839b807515a3160ac487c43315ff09 gencode/java/udmi/schema/Auth_provider.java 52c7baba2411cbbc0ab3e2723b1c235cf09ac5977ae9ac1871c179c0e7bbd9d5 gencode/java/udmi/schema/BasePodConfiguration.java a625c62703db143d05b3f6d828d8f0c4f730b506f1b12be12112fc0288f5fa5d gencode/java/udmi/schema/Basic.java 2ee7156c570a0b7ffa0c68c7600b85e1ab2791f77f9aec0ac1d8368fd7adf92e gencode/java/udmi/schema/BlobBlobsetConfig.java 387b682bde5b1b74737f2f05829b192dc9f19cc33f731b4d1ab5198a9510f5b6 gencode/java/udmi/schema/BlobBlobsetState.java c341772caa4c3b73146acac75ce21db66af9097f71a0ab85ff0e06baa4fb2ee9 gencode/java/udmi/schema/BlobUpdateTestingModel.java -ac02d059a25bb9575190d67535e29ee3f0f4e8fefd2fc027872c5d3baca6d5ff gencode/java/udmi/schema/BlobsetConfig.java +378f0771197c3f6d62dbd1384e4159778969cad425a067100e7ec384cebf45e8 gencode/java/udmi/schema/BlobsetConfig.java 92457953c4cee59a66fd0c5cd7ac58467a168484de19a39da4e005794e2eb419 gencode/java/udmi/schema/BlobsetDownload.java cc9d79ed98ab76076be089c4a2bc1e85f6b3f65591548875bf706ee0c2147a72 gencode/java/udmi/schema/BlobsetState.java b4478bfc594cd2ea9631a16b828cdda3de6447263d56fc87e62fbb5a767d05e3 gencode/java/udmi/schema/BridgePodConfiguration.java @@ -71,7 +71,7 @@ a572f17a61584a32ff19968356f7f7d7bcc8430131ac3701878d0bd221e1a1fa gencode/java/u 9217b2c963003ca51ec61b9aadd40877634f2bea08d7fd412b51521e8ec61cfb gencode/java/udmi/schema/DiscoveryEvents.java af16026c143a592562b77012dd739c02364bd1a9d6bbd6c7ede4f3a5a38b7f4a gencode/java/udmi/schema/DiscoveryModel.java 0fd348086bfd8e38a54a06b0fd205f6d01fdc6236f07ccc8bef49a1832f8c767 gencode/java/udmi/schema/DiscoveryState.java -9bc02795c4c47b16af392b2f0866138cbfe25528b20912ac0fe329c66337c638 gencode/java/udmi/schema/DiscoveryUpload.java +8e6b8605ed45af1bc6c30e1efb1a29fb745e7637df72d124c03907f8a0ac9325 gencode/java/udmi/schema/DiscoveryUpload.java baef666f5f58f790b73ba7bed8f60eacdd75ba3151b9abc32265954a9da84faa gencode/java/udmi/schema/Electricity.java 3c79394af0f0f8d6a9abd97e5fe759d606dc0a66b6b2c0b746c495600807512c gencode/java/udmi/schema/EndpointConfiguration.java 8610ae5a6a32fd17063ce7d488d4546e5ca1fd045c4cc9f0a4b94ad37f58dd60 gencode/java/udmi/schema/Entry.java @@ -93,7 +93,7 @@ cad5d302b99c488505009d32709096a24b3781c51c9f2d4312b018cd11a2e2dd gencode/java/u aa7ec0811b050bc00088b3b96f0048e779b9796ef976063ed807f4aafae4467c gencode/java/udmi/schema/GatewayModel.java acb74e9f15299bf6328c9b61f3171a264042acf1227f0179221499e98a24ae59 gencode/java/udmi/schema/GatewayState.java 9fcc5158805ecbfa43c258d8b4e5ba8940d533b0988a033833814be1f5646d67 gencode/java/udmi/schema/IotAccess.java -096e2adc70e6bac0a4906f8c20cea3be3c34eb8e91072952920b204b6a0321cc gencode/java/udmi/schema/Jwt.java +bde555af10fb11bf0423d2a77c98661d00e82f804fe8de74cb8954003811609e gencode/java/udmi/schema/Jwt.java 5a0cc53317592a868f7f23a67a7493bd571054bb25d9e28de51000ad6473dfeb gencode/java/udmi/schema/Level.java 52cee4085ee0571121061ec343f929ccc964256081a70d9579f428220209e205 gencode/java/udmi/schema/LinkExternalsModel.java 2c40b22754f0409c38394bcd1a672c5501fee147d6f0824336b41f852539ff62 gencode/java/udmi/schema/LinkExternalsSite.java @@ -107,7 +107,6 @@ e4e967da341ca824c7688b31c440b27d2247ab8e74abfefd584c28bfc99441f2 gencode/java/u 189c32fc3b4b4d5a6f011b3ef8c7119278a19fbe490b5327c029ca471cfebed6 gencode/java/udmi/schema/Monitoring.java a43b73cbf5baadc6cd054f26aa97be58d3738a046acbba7c2ccedc2a21a1d7ac gencode/java/udmi/schema/MonitoringMetric.java a1228e33420a983b1e7a208d04089fd4595a082a1f91f7f86585c31c9d9c4164 gencode/java/udmi/schema/MosquittoClientResponse.java -667d030bc13f4d82821e96e17e0d02c1d89c85bec89b3ca5d94294f097724c2f gencode/java/udmi/schema/Mtls.java 7e85b3026d88a0d26c8db2f136dd9282ab782ecc631e6a081a5008cfff20f41a gencode/java/udmi/schema/Operation.java 00c2db8f273af8ed63f8ab8dd5cc2e9c6d2139a81fe89e5fa87a275a5cb28dbb gencode/java/udmi/schema/Parameters.java 7671355028d5121231f9c978d0a33faf0c39b651526f4164dba8742c5feb5db4 gencode/java/udmi/schema/Parent.java @@ -161,7 +160,7 @@ f08d327f322431a8be0e6018a7b8a9835af8e3e6ecc4ab8e18eb36810a44e5f3 gencode/java/u 225a2e3fa5a50c197ef50b55a81dc6efd33cd09a678c2c55112858f0086fd877 gencode/java/udmi/schema/ValidationSummary.java 5ec93668911c5b801e764b07ce538ab98cb8b2d211a530bce3c902c4a7773e40 gencode/java/udmi/schema/Values.java c99d2ec53e670c3b91104a4a8fbc825c1c29530a2f686fe644f977eb6b6edd89 gencode/java/udmi/schema/Water.java -ded07912869cb93817cbf891922d2f67d983fbd10a0a2a736f8d10e05673795d gencode/presentation/presentation.json +275306b06e95d0c2cc9be716b904ba21243daf038069f4d869a03859c3de637f gencode/presentation/presentation.json 4cf98cbd132cde0cc8813ac35cf3712cb46014154c817c04ad2902c268cdd8fe gencode/python/pyproject.toml cee4b5935d1b60064b5d95c64df5ff9531e0c4b19815ef39ab185836fff50417 gencode/python/udmi/schema/__init__.py f9d90861e568b27445bef241f04cce64cc44731c95c8bd9e3f65cef79d42dab0 gencode/python/udmi/schema/_base.py @@ -172,7 +171,7 @@ de7d13c346e5d98511d02b4bdccf20afeb013eaca512694ebd7f82e584b11358 gencode/python 485009c8c773dab9f54c39efcf8e761d00059594a621435b11fc095fdcc71b29 gencode/python/udmi/schema/building_translation.py f96756baafd452dfdf34db09e93f4ca700895866a20aef6afb3395bb02369e5f gencode/python/udmi/schema/category.py e92d040fe84cc1bbc6e6b2d81a39dae156e1501e1c07a0b6cfc42e1599403e64 gencode/python/udmi/schema/commands_discovery.py -34b729f0d395cf31b7787ad0bd10693c7dafddb6188deb698ed2b5b758cc947e gencode/python/udmi/schema/common.py +655735edbeb94af016adde24e84969a56ff63e96031f9dbfc92efbb4af679d01 gencode/python/udmi/schema/common.py 59639574385a4eb18b85f8b545b69b066a45f868c4645bdaf5a87ad572772b78 gencode/python/udmi/schema/config.py 9f70f080bff148c1d31707e7f6f023ed558f738fa4db8ad3f4b71868d43fbf6a gencode/python/udmi/schema/config_alarmset.py 77c940663e59bd14bcfad0b6b83aaded3339ce032a2ef5764717c3d78b485880 gencode/python/udmi/schema/config_alarmset_alarm.py @@ -189,7 +188,7 @@ b65e7e9152629a9175f62ed706701d02814d62d8f3e9c0b46c2ba49ad8b51077 gencode/python faf4cdb1687868fadc411c144d57a5c596af790e43df4d7ddec5ee3fd10ba4bd gencode/python/udmi/schema/config_system.py ddd6e974840a19aa75e2f322e140676f71e0715f69088b88dfea76f0625ab035 gencode/python/udmi/schema/config_system_testing.py f187d5acbf931e97365af4283ee676786b7f26294db720b1c8535ab50387a6c1 gencode/python/udmi/schema/config_udmi.py -4fba5bbff9a5d1c593985e6dc610484e34213a63b74c5fd128cc85ae1e25af9d gencode/python/udmi/schema/configuration_endpoint.py +3be556292aae210f555978dee17188e2a343b7b92741a69ede1120d9074995b4 gencode/python/udmi/schema/configuration_endpoint.py 51699ab01b8d7be4db89c8e3923e0abd41a5a05e692e252deb59f10eb8189cfe gencode/python/udmi/schema/configuration_execution.py c0853ff1838a11291b53a40c43a6cdaff17951a34b3d7f3de10d9fcc7b44b79d gencode/python/udmi/schema/configuration_pod.py 62f2a07047c4cf635f2f5d021a0da433e3d38acfe9a02fcd96581aec5c1896af gencode/python/udmi/schema/configuration_pod_base.py @@ -267,4 +266,4 @@ e23b1b67fc5066ee0c9a948fbc0b31bc425d73740a8d2104b30004173f049f4a gencode/python 6c62d12f392525e4c08d3ee6105f475b941c21e0a8b7a23eaf1650d01a5b5aae gencode/python/udmi/schema/state_validation_feature.py 8a92d352337c118831e3a3b64b350a5ab248fc29150e7cdb968d5e5cf0aae30c gencode/python/udmi/schema/state_validation_schema.py 8862c955ea0aee91db532cb076112305ce6e18d9ac5b323f758fd7fcf75db7cd gencode/python/udmi/schema/state_validation_sequence.py -bc37f27cf5c91e564e55e2d31adda5753b13339f5b3368327153258131d5cee4 gencode/python/udmi/schema/upload_discovery.py +53a16fed02ddd4f5f9f1cd176222f563b390a76e5d346da2e32fd55ee1faa146 gencode/python/udmi/schema/upload_discovery.py diff --git a/butler/bin/butler b/butler/bin/butler index f3295fc7fc..06a2339d5d 100755 --- a/butler/bin/butler +++ b/butler/bin/butler @@ -22,6 +22,7 @@ sys.path.insert(0, REPO_ROOT) from butler.src.discovery import run_discovery from butler.src.mapping import run_mapping +from butler.src.reassemble import run_reassemble from butler.src.service import ButlerService @@ -40,9 +41,9 @@ def get_registry_id(site_model: str) -> str: def main(): raw_args = sys.argv[1:] - # Check if this is a mapping/discovery command - # e.g.: butler [targets...] - mapping_commands = {"provision", "discover", "map"} + # Check if this is a mapping/discovery/reassemble command + # e.g.: butler [targets...] + mapping_commands = {"provision", "discover", "map", "reassemble"} is_mapping_cmd = False for arg in raw_args: if arg in mapping_commands: @@ -54,7 +55,7 @@ def main(): parser.add_argument("site_model") parser.add_argument("project_spec") parser.add_argument("device_id") - parser.add_argument("command", choices=["provision", "discover", "map"]) + parser.add_argument("command", choices=["provision", "discover", "map", "reassemble"]) parser.add_argument("targets", nargs="*") parser.add_argument("--butler-port", type=int, default=None, help="Butler MCP server port") args = parser.parse_args() @@ -71,6 +72,12 @@ def main(): args.targets, butler_port=args.butler_port, ) + elif args.command == "reassemble": + if len(args.targets) != 2: + raise ValueError( + "reassemble requires and target arguments" + ) + run_reassemble(args.targets[0], args.targets[1]) elif args.command == "provision": pass return diff --git a/butler/src/dispatcher.py b/butler/src/dispatcher.py index 7bcd2abbba..d0cc4dbe0c 100644 --- a/butler/src/dispatcher.py +++ b/butler/src/dispatcher.py @@ -56,6 +56,9 @@ def dispatch(self, envelope: Dict[str, Any], payload: Dict[str, Any]) -> Dict[st sub_folder = envelope.get("subFolder") sub_type = envelope.get("subType") + if sub_type == "download": + return {"target": "ignored", "table": None, "count": 0} + matched_handler = None for handler in self.handlers: if handler.can_handle(sub_folder, sub_type, payload): diff --git a/butler/src/handlers/discovery.py b/butler/src/handlers/discovery.py index 5370102172..3b83595e57 100644 --- a/butler/src/handlers/discovery.py +++ b/butler/src/handlers/discovery.py @@ -23,7 +23,7 @@ class DiscoveryHandler(BaseHandler): """Processes discovery events and saves rows into PostgreSQL udmi_discovery table.""" def can_handle(self, sub_folder: Optional[str], sub_type: Optional[str], payload: Dict[str, Any]) -> bool: - return sub_folder == "discovery" + return sub_folder == "discovery" and sub_type in ("events", None) def process_message( self, diff --git a/butler/src/reassemble.py b/butler/src/reassemble.py new file mode 100644 index 0000000000..bd04804afb --- /dev/null +++ b/butler/src/reassemble.py @@ -0,0 +1,152 @@ +"""Generic chunked upload reassembly utility for UDMI upload/* streams.""" + +import base64 +import hashlib +import json +import os +import sys +from typing import Any, Dict, List + +PCAP_MAGIC_NUMBERS = ( + b"\xa1\xb2\xc3\xd4", # Standard pcap (same-endian) + b"\xd4\xc3\xb2\xa1", # Standard pcap (swapped-endian) + b"\xa1\xb2\x3c\x4d", # Nanosecond pcap (same-endian) + b"\x4d\x3c\xb2\xa1", # Nanosecond pcap (swapped-endian) +) + + +def parse_stream_events(raw_text: str) -> List[Dict[str, Any]]: + """Parse upload chunk objects from JSON array, JSONL, or mosquitto_sub output.""" + raw_text = raw_text.strip() + if not raw_text: + return [] + + if raw_text.startswith("["): + try: + data = json.loads(raw_text) + if isinstance(data, list): + return [ + item.get("payload", item) + if isinstance(item, dict) and isinstance(item.get("payload"), dict) + else item + for item in data + if isinstance(item, dict) + ] + except json.JSONDecodeError: + pass + + events: List[Dict[str, Any]] = [] + for line in raw_text.splitlines(): + line = line.strip() + if not line: + continue + if " " in line and not line.startswith("{"): + _, _, payload = line.partition(" ") + else: + payload = line + + try: + obj = json.loads(payload) + if isinstance(obj, dict): + inner = ( + obj["payload"] + if isinstance(obj.get("payload"), dict) + and ("event_no" in obj["payload"] or "data" in obj["payload"]) + else obj + ) + if "event_no" in inner or "data" in inner or "sha256" in inner: + events.append(inner) + except json.JSONDecodeError: + continue + + return events + + +def _assemble_and_verify( + sorted_events: List[Dict[str, Any]], eof_events: List[Dict[str, Any]] +) -> bytes: + """Validates sequential event_no, decodes Base64 chunks, and checks SHA-256.""" + total_chunks = len(sorted_events) + if eof_events: + terminal_event_no = eof_events[-1].get("event_no") + if terminal_event_no is not None and int(terminal_event_no) < 0: + expected_chunks = -(int(terminal_event_no) + 1) + if expected_chunks != total_chunks: + raise ValueError( + f"Chunk count mismatch: terminal event_no {terminal_event_no} " + f"expects {expected_chunks} chunks, found {total_chunks}." + ) + + assembled_data = bytearray() + for idx, evt in enumerate(sorted_events, start=1): + event_no = evt.get("event_no") + if event_no is not None and int(event_no) != idx: + raise ValueError( + f"Non-contiguous upload chunk sequence: expected event_no {idx}, got {event_no}." + ) + try: + assembled_data.extend(base64.b64decode(evt["data"])) + except Exception as err: + raise ValueError( + f"Failed to decode base64 chunk at event_no {idx}: {err}" + ) from err + + if not assembled_data: + raise ValueError("Reassembled upload payload is empty (0 bytes).") + + if eof_events and eof_events[-1].get("sha256"): + expected_sha256 = str(eof_events[-1]["sha256"]).lower() + actual_sha256 = hashlib.sha256(assembled_data).hexdigest().lower() + if actual_sha256 != expected_sha256: + raise ValueError( + f"SHA-256 mismatch (expected {expected_sha256}, got {actual_sha256})." + ) + return bytes(assembled_data) + + +def run_reassemble(input_source: str, output_path: str) -> None: + """Reassembles chunked UDMI upload records into a binary artifact file.""" + if input_source == "-": + raw_content = sys.stdin.read() + else: + if not os.path.exists(input_source): + raise FileNotFoundError(f"Input file '{input_source}' does not exist.") + with open(input_source, "r", encoding="utf-8", errors="replace") as f: + raw_content = f.read() + + events = parse_stream_events(raw_content) + if not events: + raise ValueError("No valid upload records found in input.") + + data_events = [ + e for e in events if e.get("data") and (e.get("event_no") or 1) > 0 + ] + if not data_events: + raise ValueError("Found upload records but none contained base64 'data'.") + + eof_events = [ + e for e in events if e.get("sha256") or (e.get("event_no") or 0) < 0 + ] + sorted_events = sorted(data_events, key=lambda e: int(e.get("event_no", 0))) + assembled_data = _assemble_and_verify(sorted_events, eof_events) + + os.makedirs(os.path.dirname(os.path.abspath(output_path)), exist_ok=True) + with open(output_path, "wb") as f: + f.write(assembled_data) + + magic = assembled_data[:4] + magic_status = ( + "VALID PCAP" if any(magic == m for m in PCAP_MAGIC_NUMBERS) else "BINARY" + ) + first_event = events[0] + print("========================================") + print("Upload Reassembly Completed Successfully") + print("========================================") + print( + f"Family / Gen : {first_event.get('family', 'unknown')} / " + f"{first_event.get('generation', 'unknown')}" + ) + print(f"Chunks Decoded : {len(sorted_events)}") + print(f"Total Bytes : {len(assembled_data)}") + print(f"Magic Bytes : {magic.hex()} ({magic_status})") + print(f"Output File : {output_path}") diff --git a/butler/src/service.py b/butler/src/service.py index bd6798d3c4..40548db8b8 100644 --- a/butler/src/service.py +++ b/butler/src/service.py @@ -56,7 +56,7 @@ def parse_mqtt_topic(topic: str) -> Dict[str, Optional[str]]: sub_type = parts[r_idx + 2] if r_idx + 3 < len(parts): sub_folder = parts[r_idx + 3] - elif len(parts) > 0 and parts[0] in ("events", "state", "commands", "config"): + elif len(parts) > 0 and parts[0] in ("events", "state", "commands", "config", "upload", "download"): sub_type = parts[0] if len(parts) > 1: sub_folder = parts[1] diff --git a/butler/tests/test_dispatcher.py b/butler/tests/test_dispatcher.py index c3ca0cfc5f..a57e9068bb 100644 --- a/butler/tests/test_dispatcher.py +++ b/butler/tests/test_dispatcher.py @@ -56,6 +56,56 @@ def test_dispatch_fallback(self): self.assertEqual(res["target"], "postgres") self.assertEqual(res["table"], "udmi_messages") + def test_dispatch_upload_discovery_fallback(self): + envelope = {"subFolder": "discovery", "subType": "upload", "deviceId": "DEV-1"} + payload = {"family": "bacnet", "generation": "2026-04-06T12:00:00Z", "event_no": 0} + + res = self.dispatcher.dispatch(envelope, payload) + self.assertEqual(res["target"], "postgres") + self.assertEqual(res["table"], "udmi_messages") + + def test_dispatch_download_ignored(self): + envelope = {"subFolder": "blobset", "subType": "download", "deviceId": "DEV-1"} + payload = {"blobs": {"_iot_endpoint_config": {"hostname": "mqtt.example.com"}}} + + res = self.dispatcher.dispatch(envelope, payload) + self.assertEqual(res["target"], "ignored") + self.assertEqual(res["count"], 0) + self.mock_pg.insert_raw_message.assert_not_called() + + def test_reassemble_upload(self): + import base64 + import hashlib + import json + import tempfile + from butler.src.reassemble import run_reassemble + + raw_bytes = b"\xa1\xb2\xc3\xd4PCAP-PAYLOAD" + sha256_hex = hashlib.sha256(raw_bytes).hexdigest() + lines = [ + json.dumps({"family": "ether", "generation": "2026-07-08T14:35:00Z", "event_no": 0}), + json.dumps({ + "family": "ether", + "generation": "2026-07-08T14:35:00Z", + "event_no": 1, + "data": base64.b64encode(raw_bytes).decode(), + }), + json.dumps({ + "family": "ether", + "generation": "2026-07-08T14:35:00Z", + "event_no": -2, + "sha256": sha256_hex, + }), + ] + with tempfile.TemporaryDirectory() as tmpdir: + in_path = os.path.join(tmpdir, "stream.jsonl") + out_path = os.path.join(tmpdir, "out.pcap") + with open(in_path, "w", encoding="utf-8") as f: + f.write("\n".join(lines)) + run_reassemble(in_path, out_path) + with open(out_path, "rb") as f: + self.assertEqual(f.read(), raw_bytes) + if __name__ == "__main__": unittest.main() diff --git a/butler/tests/test_handlers.py b/butler/tests/test_handlers.py index 18df34746b..f98e89620a 100644 --- a/butler/tests/test_handlers.py +++ b/butler/tests/test_handlers.py @@ -92,6 +92,8 @@ def test_system_state_handler(self): def test_discovery_handler(self): handler = DiscoveryHandler() self.assertTrue(handler.can_handle("discovery", "events", {})) + self.assertFalse(handler.can_handle("discovery", "upload", {})) + self.assertFalse(handler.can_handle("discovery", "state", {})) envelope = {"deviceId": "GAT-1", "deviceRegistryId": "reg-1"} payload = { diff --git a/clientlib/python/src/udmi/core/device.py b/clientlib/python/src/udmi/core/device.py index d4ba37add2..1b9f69cedc 100644 --- a/clientlib/python/src/udmi/core/device.py +++ b/clientlib/python/src/udmi/core/device.py @@ -6,6 +6,8 @@ """ import datetime +import hashlib +import json import logging import threading import time @@ -36,6 +38,7 @@ from udmi.schema import BlobsetState from udmi.schema import Config from udmi.schema import EndpointConfiguration +from udmi.schema import Entry from udmi.schema import Metadata from udmi.schema import State from udmi.schema import SystemState @@ -43,6 +46,21 @@ LOGGER = logging.getLogger(__name__) + +def _canonical_json_sha256(obj: Any) -> str: + """Computes the SHA-256 hex digest of an object's canonical terse JSON.""" + def _strip_nones(val: Any) -> Any: + if isinstance(val, dict): + return {k: _strip_nones(v) for k, v in val.items() if v is not None} + if isinstance(val, list): + return [_strip_nones(v) for v in val] + return val + + canonical = json.dumps( + _strip_nones(obj), sort_keys=True, separators=(",", ":") + ) + return hashlib.sha256(canonical.encode("utf-8")).hexdigest() + T = TypeVar("T", bound=BaseManager) MAX_CONNECTION_RETRIES = 3 STATE_THROTTLE_SEC = 2.0 @@ -362,21 +380,12 @@ def handle_download( blobset_download.blobs and IOT_ENDPOINT_CONFIG_BLOB_KEY in blobset_download.blobs ): - new_endpoint = blobset_download.blobs[ - IOT_ENDPOINT_CONFIG_BLOB_KEY - ] - generation = ( - self._pending_endpoint_blob.generation - if self._pending_endpoint_blob - else None - ) or "download" - self._endpoint_blob_state = BlobBlobsetState( - phase=Phase.final, - generation=generation, + self._process_endpoint_download( + blobset_download.blobs[IOT_ENDPOINT_CONFIG_BLOB_KEY], + payload.get("blobs", {}).get( + IOT_ENDPOINT_CONFIG_BLOB_KEY, {} + ), ) - self._pending_endpoint_blob = None - self._apply_redirect_endpoint(new_endpoint, generation) - self._publish_state(bypass_throttle=True, blocking=True) except (TypeError, ValueError) as e: LOGGER.error("Failed to parse download/blobset message: %s", e) @@ -391,6 +400,71 @@ def handle_download( e, ) + def _process_endpoint_download( + self, new_endpoint: EndpointConfiguration, raw_blob: Dict[str, Any] + ) -> None: + """Validates and applies an EndpointConfiguration from download/blobset.""" + if self._pending_endpoint_blob is None: + LOGGER.warning( + "Ignoring unsolicited endpoint download for %s", self.device_id + ) + return + generation = self._pending_endpoint_blob.generation or "download" + expected_sha = self._pending_endpoint_blob.sha256 + error_msg = self._validate_downloaded_endpoint( + new_endpoint, raw_blob, expected_sha + ) + self._pending_endpoint_blob = None + if error_msg: + LOGGER.error("%s", error_msg) + self._endpoint_blob_state = BlobBlobsetState( + phase=Phase.final, + generation=generation, + status=Entry( + category="blobset.blob.apply", + level=500, + message=error_msg, + timestamp=datetime.datetime.now( + datetime.timezone.utc + ).isoformat(), + ), + ) + self._publish_state(bypass_throttle=True, blocking=True) + return + + self._endpoint_blob_state = BlobBlobsetState( + phase=Phase.final, + generation=generation, + ) + self._publish_state(bypass_throttle=True, blocking=True) + self._apply_redirect_endpoint(new_endpoint, generation) + + @staticmethod + def _validate_downloaded_endpoint( + new_endpoint: EndpointConfiguration, + raw_blob: Dict[str, Any], + expected_sha: Optional[str], + ) -> Optional[str]: + """Checks SHA-256 integrity and expiry on a downloaded endpoint blob.""" + if not expected_sha: + return "Missing expected SHA-256 hash for downloaded endpoint blob" + actual_sha = _canonical_json_sha256(raw_blob) + if actual_sha.lower() != expected_sha.lower(): + return ( + "Downloaded endpoint blob SHA-256 mismatch: " + f"expected {expected_sha}, got {actual_sha}" + ) + if new_endpoint.expiry: + expiry_dt = datetime.datetime.fromisoformat( + new_endpoint.expiry.replace("Z", "+00:00") + ) + if expiry_dt <= datetime.datetime.now(datetime.timezone.utc): + return ( + "Downloaded endpoint configuration is expired: " + f"{new_endpoint.expiry}" + ) + return None + # --- Proxy Routing Helpers --- def _route_proxy_config(self, device_id: str, payload: Dict) -> None: @@ -466,6 +540,12 @@ def _has_new_endpoint_config(self, config: Config) -> bool: if not blob_config: return False + if ( + self._endpoint_blob_state + and self._endpoint_blob_state.generation == blob_config.generation + ): + return False + current_gen = self.persistence.get_active_generation() if blob_config.generation and blob_config.generation != current_gen: return True @@ -659,6 +739,10 @@ def _publish_state( self.state.blobset.blobs[IOT_ENDPOINT_CONFIG_BLOB_KEY] = ( self._endpoint_blob_state ) + elif self.state.blobset and self.state.blobset.blobs: + self.state.blobset.blobs.pop(IOT_ENDPOINT_CONFIG_BLOB_KEY, None) + if not self.state.blobset.blobs: + self.state.blobset = None should_block = bypass_throttle if blocking is None else blocking self.dispatcher.publish_state(self.state, wait=should_block) self._loop_state.last_state_publish_time = time.time() diff --git a/clientlib/python/src/udmi/core/managers/gateway_manager.py b/clientlib/python/src/udmi/core/managers/gateway_manager.py index 96b690338d..4b15adf894 100644 --- a/clientlib/python/src/udmi/core/managers/gateway_manager.py +++ b/clientlib/python/src/udmi/core/managers/gateway_manager.py @@ -31,6 +31,8 @@ ProxyConfigHandler = Callable[[str, Config], None] # ProxyCommandHandler: (device_id, command_name, payload) -> None ProxyCommandHandler = Callable[[str, str, dict], None] +# ProxyDownloadHandler: (device_id, download_name, payload) -> None +ProxyDownloadHandler = Callable[[str, str, dict], None] class GatewayManager(BaseManager): @@ -39,6 +41,8 @@ class GatewayManager(BaseManager): Allows this device to act as a gateway for other 'proxy' devices. """ + # pylint: disable=too-many-instance-attributes + @property def model_field_name(self) -> str: return "gateway" @@ -56,8 +60,10 @@ def __init__(self) -> None: self._proxies: List[str] = [] self._proxy_config_handlers: Dict[str, ProxyConfigHandler] = {} self._proxy_command_handlers: Dict[str, ProxyCommandHandler] = {} + self._proxy_download_handlers: Dict[str, ProxyDownloadHandler] = {} self._default_config_handler: Optional[ProxyConfigHandler] = None self._default_command_handler: Optional[ProxyCommandHandler] = None + self._default_download_handler: Optional[ProxyDownloadHandler] = None self._gateway_state = GatewayState() LOGGER.info("GatewayManager initialized.") @@ -168,9 +174,13 @@ def update_state(self, state: State) -> None: # --- Public API --- - def add_proxy(self, device_id: str, - config_handler: Optional[ProxyConfigHandler] = None, - command_handler: Optional[ProxyCommandHandler] = None) -> None: + def add_proxy( + self, + device_id: str, + config_handler: Optional[ProxyConfigHandler] = None, + command_handler: Optional[ProxyCommandHandler] = None, + download_handler: Optional[ProxyDownloadHandler] = None, + ) -> None: """ Registers a proxy device, persists it, and sends the 'attach' message. @@ -178,6 +188,7 @@ def add_proxy(self, device_id: str, device_id: The unique ID of the proxy device. config_handler: Optional callback for this proxy's config updates. command_handler: Optional callback for this proxy's commands. + download_handler: Optional callback for this proxy's downloads. """ if device_id not in self._proxies: self._proxies.append(device_id) @@ -189,11 +200,15 @@ def add_proxy(self, device_id: str, device_id) self._dispatcher.client.register_channel_subscription("commands/#", device_id) + self._dispatcher.client.register_channel_subscription("download/#", + device_id) if config_handler: self._proxy_config_handlers[device_id] = config_handler if command_handler: self._proxy_command_handlers[device_id] = command_handler + if download_handler: + self._proxy_download_handlers[device_id] = download_handler def remove_proxy(self, device_id: str) -> None: """ @@ -206,6 +221,7 @@ def remove_proxy(self, device_id: str) -> None: self._proxies.remove(device_id) self._proxy_config_handlers.pop(device_id, None) self._proxy_command_handlers.pop(device_id, None) + self._proxy_download_handlers.pop(device_id, None) LOGGER.info("Detached proxy device: %s", device_id) self._save_proxies() if self._dispatcher: @@ -249,6 +265,28 @@ def handle_proxy_command(self, device_id: str, command_name: str, payload: dict) except Exception as e: # pylint: disable=broad-exception-caught LOGGER.error("Error in proxy command handler for %s: %s", device_id, e) + def handle_proxy_download( + self, device_id: str, download_name: str, payload: dict + ) -> None: + """ + Routes downloads to the specific proxy handler. + + Args: + device_id: The ID of the proxy device receiving the download. + download_name: The name of the download channel (e.g., 'blobset'). + payload: The download payload dictionary. + """ + handler = self._proxy_download_handlers.get( + device_id, self._default_download_handler + ) + if handler: + try: + handler(device_id, download_name, payload) + except Exception as e: # pylint: disable=broad-exception-caught + LOGGER.error( + "Error in proxy download handler for %s: %s", device_id, e + ) + def _send_attach_message(self, device_id: str) -> None: """ Publishes an MQTT 'attach' message to the broker for the given device. @@ -292,14 +330,20 @@ def publish_proxy_event(self, device_id: str, event_model: DataModel, subfolder: if self._dispatcher: self._dispatcher.publish_event(f"events/{subfolder}", event_model, device_id=device_id) - def set_default_handlers(self, config_handler: Optional[ProxyConfigHandler] = None, - command_handler: Optional[ProxyCommandHandler] = None) -> None: + def set_default_handlers( + self, + config_handler: Optional[ProxyConfigHandler] = None, + command_handler: Optional[ProxyCommandHandler] = None, + download_handler: Optional[ProxyDownloadHandler] = None, + ) -> None: """ Sets the default handlers for proxies that don't have specific ones registered. Args: config_handler: The default callback for config updates. command_handler: The default callback for commands. + download_handler: The default callback for downloads. """ self._default_config_handler = config_handler self._default_command_handler = command_handler + self._default_download_handler = download_handler diff --git a/clientlib/python/src/udmi/core/managers/system_manager.py b/clientlib/python/src/udmi/core/managers/system_manager.py index 7941888b63..ef5f0fa24d 100644 --- a/clientlib/python/src/udmi/core/managers/system_manager.py +++ b/clientlib/python/src/udmi/core/managers/system_manager.py @@ -516,7 +516,7 @@ def update_state(self, state: State) -> None: state.system = self._system_state if self._blob_states: - state.blobset = BlobsetState(blobs=self._blob_states) + state.blobset = BlobsetState(blobs=dict(self._blob_states)) def _publish_startup_event(self) -> None: """Publishes the system startup event log.""" diff --git a/clientlib/python/tests/core/managers/test_gateway_manager.py b/clientlib/python/tests/core/managers/test_gateway_manager.py index f39626d33b..609efa5ed1 100644 --- a/clientlib/python/tests/core/managers/test_gateway_manager.py +++ b/clientlib/python/tests/core/managers/test_gateway_manager.py @@ -94,7 +94,8 @@ def test_add_proxy_flow(manager, mock_device, mock_dispatcher): expected_subs = [ call("config", "new-proxy"), - call("commands/#", "new-proxy") + call("commands/#", "new-proxy"), + call("download/#", "new-proxy"), ] client.register_channel_subscription.assert_has_calls(expected_subs, any_order=True) @@ -191,10 +192,14 @@ def test_proxy_message_routing(manager): """ mock_config_handler = MagicMock() mock_command_handler = MagicMock() + mock_download_handler = MagicMock() - manager.add_proxy("proxy-1", - config_handler=mock_config_handler, - command_handler=mock_command_handler) + manager.add_proxy( + "proxy-1", + config_handler=mock_config_handler, + command_handler=mock_command_handler, + download_handler=mock_download_handler, + ) config_obj = Config(timestamp="now") manager.handle_proxy_config("proxy-1", config_obj) @@ -204,6 +209,12 @@ def test_proxy_message_routing(manager): manager.handle_proxy_command("proxy-1", "reset", payload) mock_command_handler.assert_called_once_with("proxy-1", "reset", payload) + download_payload = {"blobs": {}} + manager.handle_proxy_download("proxy-1", "blobset", download_payload) + mock_download_handler.assert_called_once_with( + "proxy-1", "blobset", download_payload + ) + def test_proxy_routing_exception_safety(manager, caplog): """ diff --git a/clientlib/python/tests/core/test_device.py b/clientlib/python/tests/core/test_device.py index ad28d33e99..e546238177 100644 --- a/clientlib/python/tests/core/test_device.py +++ b/clientlib/python/tests/core/test_device.py @@ -22,6 +22,8 @@ invalid config is not passed to managers. """ +import hashlib +import json import logging import time from unittest.mock import MagicMock @@ -242,10 +244,21 @@ def test_download_channel_redirection_flow(test_device, mock_dispatcher): test_device.persistence = mock_persistence mock_persistence.get_active_generation.return_value = "old-gen" + endpoint_blob_dict = { + "protocol": "mqtt", + "hostname": "new.broker.local", + "port": 8883, + "client_id": "projects/p/locations/l/registries/r/devices/d", + } + + valid_sha = hashlib.sha256( + json.dumps(endpoint_blob_dict, sort_keys=True, separators=(",", ":")).encode("utf-8") + ).hexdigest() + blob_config = BlobBlobsetConfig( generation="new-gen", url="download://blobset/_iot_endpoint_config", - sha256="deadbeef", + sha256=valid_sha, ) config = Config( blobset=BlobsetConfig( @@ -269,12 +282,7 @@ def test_download_channel_redirection_flow(test_device, mock_dispatcher): "timestamp": "2026-03-31T10:00:00Z", "version": "1.5.7", "blobs": { - IOT_ENDPOINT_CONFIG_BLOB_KEY: { - "protocol": "mqtt", - "hostname": "new.broker.local", - "port": 8883, - "client_id": "projects/p/locations/l/registries/r/devices/d", - } + IOT_ENDPOINT_CONFIG_BLOB_KEY: endpoint_blob_dict, }, } test_device.handle_download( @@ -289,9 +297,207 @@ def test_download_channel_redirection_flow(test_device, mock_dispatcher): test_device.state.blobset.blobs[IOT_ENDPOINT_CONFIG_BLOB_KEY].phase.value == "final" ) + assert ( + test_device.state.blobset.blobs[IOT_ENDPOINT_CONFIG_BLOB_KEY].status + is None + ) assert test_device._loop_state.reset_event.is_set() +def test_download_channel_bad_sha256(test_device): + """ + Verifies that a downloaded endpoint blob with a mismatched SHA-256 hash + is rejected and transitions state to 'final' with a level 500 error status. + """ + mock_persistence = MagicMock() + test_device.persistence = mock_persistence + mock_persistence.get_active_generation.return_value = "old-gen" + + blob_config = BlobBlobsetConfig( + generation="new-gen", + url="download://blobset/_iot_endpoint_config", + sha256="0" * 64, + ) + config = Config( + blobset=BlobsetConfig( + blobs={IOT_ENDPOINT_CONFIG_BLOB_KEY: blob_config} + ) + ) + + with patch("src.udmi.core.device.STATE_THROTTLE_SEC", 0): + test_device.handle_config( + test_device.device_id, "config", config.to_dict() + ) + + download_payload = { + "timestamp": "2026-03-31T10:00:00Z", + "version": "1.5.7", + "blobs": { + IOT_ENDPOINT_CONFIG_BLOB_KEY: { + "protocol": "mqtt", + "hostname": "new.broker.local", + "port": 8883, + "client_id": "projects/p/locations/l/registries/r/devices/d", + } + }, + } + test_device.handle_download( + test_device.device_id, "download/blobset", download_payload + ) + + mock_persistence.save_active_endpoint.assert_not_called() + assert not test_device._loop_state.reset_event.is_set() + blob_state = test_device.state.blobset.blobs[IOT_ENDPOINT_CONFIG_BLOB_KEY] + assert blob_state.phase.value == "final" + assert blob_state.status is not None + assert blob_state.status.level == 500 + assert blob_state.status.category == "blobset.blob.apply" + + +def test_download_channel_expired_endpoint(test_device): + """ + Verifies that an already-expired downloaded endpoint configuration is + rejected and transitions state to 'final' with a level 500 error status. + """ + + mock_persistence = MagicMock() + test_device.persistence = mock_persistence + mock_persistence.get_active_generation.return_value = "old-gen" + + expired_blob = { + "protocol": "mqtt", + "hostname": "new.broker.local", + "port": 8883, + "client_id": "projects/p/locations/l/registries/r/devices/d", + "expiry": "2020-01-01T00:00:00Z", + } + valid_sha = hashlib.sha256( + json.dumps( + expired_blob, sort_keys=True, separators=(",", ":") + ).encode("utf-8") + ).hexdigest() + + blob_config = BlobBlobsetConfig( + generation="new-gen", + url="download://blobset/_iot_endpoint_config", + sha256=valid_sha, + ) + config = Config( + blobset=BlobsetConfig( + blobs={IOT_ENDPOINT_CONFIG_BLOB_KEY: blob_config} + ) + ) + + with patch("src.udmi.core.device.STATE_THROTTLE_SEC", 0): + test_device.handle_config( + test_device.device_id, "config", config.to_dict() + ) + + download_payload = { + "timestamp": "2026-03-31T10:00:00Z", + "version": "1.5.7", + "blobs": { + IOT_ENDPOINT_CONFIG_BLOB_KEY: expired_blob, + }, + } + test_device.handle_download( + test_device.device_id, "download/blobset", download_payload + ) + + mock_persistence.save_active_endpoint.assert_not_called() + assert not test_device._loop_state.reset_event.is_set() + blob_state = test_device.state.blobset.blobs[IOT_ENDPOINT_CONFIG_BLOB_KEY] + assert blob_state.phase.value == "final" + assert blob_state.status is not None + assert blob_state.status.level == 500 + assert blob_state.status.category == "blobset.blob.apply" + + # Clearing config.blobset must remove _iot_endpoint_config from state.blobset + with patch("src.udmi.core.device.STATE_THROTTLE_SEC", 0): + test_device.handle_config( + test_device.device_id, "config", Config().to_dict() + ) + assert test_device.state.blobset is None + + +def test_download_channel_unsolicited_ignored(test_device): + """ + Verifies that an unsolicited download/blobset message arriving when no + download://blobset/_iot_endpoint_config is pending is ignored. + """ + mock_persistence = MagicMock() + test_device.persistence = mock_persistence + + download_payload = { + "timestamp": "2026-03-31T10:00:00Z", + "version": "1.5.7", + "blobs": { + IOT_ENDPOINT_CONFIG_BLOB_KEY: { + "protocol": "mqtt", + "hostname": "unsolicited.broker.local", + "port": 8883, + "client_id": "projects/p/locations/l/registries/r/devices/d", + } + }, + } + test_device.handle_download( + test_device.device_id, "download/blobset", download_payload + ) + + mock_persistence.save_active_endpoint.assert_not_called() + assert not test_device._loop_state.reset_event.is_set() + assert test_device.state.blobset is None + + +def test_download_channel_missing_sha256(test_device): + """ + Verifies that a downloaded endpoint blob when config.blobset has no + expected sha256 is rejected with level 500 error status. + """ + mock_persistence = MagicMock() + test_device.persistence = mock_persistence + mock_persistence.get_active_generation.return_value = "old-gen" + + blob_config = BlobBlobsetConfig( + generation="new-gen", + url="download://blobset/_iot_endpoint_config", + ) + config = Config( + blobset=BlobsetConfig( + blobs={IOT_ENDPOINT_CONFIG_BLOB_KEY: blob_config} + ) + ) + + with patch("src.udmi.core.device.STATE_THROTTLE_SEC", 0): + test_device.handle_config( + test_device.device_id, "config", config.to_dict() + ) + + download_payload = { + "timestamp": "2026-03-31T10:00:00Z", + "version": "1.5.7", + "blobs": { + IOT_ENDPOINT_CONFIG_BLOB_KEY: { + "protocol": "mqtt", + "hostname": "new.broker.local", + "port": 8883, + "client_id": "projects/p/locations/l/registries/r/devices/d", + } + }, + } + test_device.handle_download( + test_device.device_id, "download/blobset", download_payload + ) + + mock_persistence.save_active_endpoint.assert_not_called() + assert not test_device._loop_state.reset_event.is_set() + blob_state = test_device.state.blobset.blobs[IOT_ENDPOINT_CONFIG_BLOB_KEY] + assert blob_state.phase.value == "final" + assert blob_state.status is not None + assert blob_state.status.level == 500 + assert blob_state.status.category == "blobset.blob.apply" + + # --- Resilience / Fallback Tests --- def test_connection_fallback_clears_active_endpoint(test_device): diff --git a/docs/specs/blob_updates.md b/docs/specs/blob_updates.md index c54b8c5fb3..3eef3a0493 100644 --- a/docs/specs/blob_updates.md +++ b/docs/specs/blob_updates.md @@ -142,7 +142,7 @@ Granular observability relies on consistent telemetry. When tracking the lifecyc ## Ephemeral Blobset Downloads (`download/blobset`) -When delivering sensitive credentials, private keys, or ephemeral tokens (`_iot_endpoint_config`, `_bacnet_sc_config`, `_discovery_auth`), embedding secret material inline in `config.blobset` (via `data:` URLs) is undesirable because `config` messages are retained by the MQTT broker and stored in operational databases. +When delivering sensitive credentials or ephemeral tokens (`_iot_endpoint_config`), embedding secret material inline in `config.blobset` (via `data:` URLs) is undesirable because `config` messages are retained by the MQTT broker and stored in operational databases. Instead, the cloud can specify a `download://` URL (e.g., `download://_iot_endpoint_config`) in `config.blobset.blobs..url` and deliver the sensitive payload out-of-band over the `{topic_prefix}/download/blobset` MQTT topic ([`schema/download_blobset.json`](../../schema/download_blobset.json)). @@ -153,27 +153,22 @@ Instead, the cloud can specify a `download://` URL (e.g., `download://_iot_endpo ### System Blobset Keys (`schema/common.json#/definitions/blobsets`) Standardized UDMI system blob keys use a leading underscore: * `_iot_endpoint_config`: Primary MQTT broker endpoint configuration and credentials (including automated key/password rotation). -* `_bacnet_sc_config`: BACnet Secure Connect (BACnet/SC) operational certificates, private keys, and CA bundle. -* `_discovery_auth`: Ephemeral local discovery authentication credentials (e.g., vendor controller API username/password or token). ### `EndpointConfiguration` Extensions (`schema/configuration_endpoint.json`) Each entry in `download/blobset` (`blobs.`) conforms to [`EndpointConfiguration`](../../schema/configuration_endpoint.json), which supports: * **`expiry`** (`date-time`): Optional RFC 3339 UTC timestamp indicating when an ephemeral credential expires. Once `now >= expiry`, the device must purge the credential from memory and, if the blob is still active in `config.blobset`, transition `state.blobset.blobs..phase` back to `apply` to request a refreshed credential. * **`auth_provider.basic`**: `username` and `password`. -* **`auth_provider.jwt`**: `audience` and optional pre-signed `token`. -* **`auth_provider.mtls`**: - * `private_key`: Client private key PEM for mTLS authentication. - * `certificate`: Client X.509 certificate PEM for mTLS authentication. - * `ca_certificate`: Trusted CA certificate bundle PEM for verifying the peer or hub. +* **`auth_provider.jwt`**: `audience`. ### Handshake & Crash Recovery Flow 1. **Config Notification**: Cloud publishes `config.blobset.blobs.` with `phase: "final"`, `generation`, `sha256` of the serialized `EndpointConfiguration`, and `url: "download://"`. 2. **Download Request (`phase: "apply"`)**: Seeing a new `generation` with a `download://` URL (or recovering after a reboot/crash where the ephemeral secret is no longer in memory), the device publishes `state.blobset.blobs..phase = "apply"` with the matching `generation`. 3. **Ephemeral Delivery**: Upon receiving `state.blobset.blobs..phase == "apply"`, UDMIS publishes a [`BlobsetDownload`](../../schema/download_blobset.json) message to `{topic_prefix}/download/blobset` containing `blobs.`. -4. **Application & Acknowledgement (`phase: "final"`)**: The device applies the `EndpointConfiguration` and publishes `state.blobset.blobs..phase = "final"`. +4. **Application & Acknowledgement (`phase: "final"`)**: The device verifies that the SHA-256 digest of the received payload matches `config.blobset.blobs..sha256`, applies the `EndpointConfiguration`, and publishes `state.blobset.blobs..phase = "final"`. ### Coexistence with Inline `data:` URLs To maintain backward compatibility with existing devices and test sequences: * **`data:` URLs**: Non-secret endpoint redirections (such as updating `hostname`, `port`, or `client_id` while reusing an existing on-device private key) continue to use `data:application/json;base64,...` inline in `config.blobset.blobs._iot_endpoint_config.url`. Clients decode and apply `data:` URLs immediately upon receiving `config`. -* **`download://` URLs**: Secret-bearing blobs (`basic.password`, `jwt.token`, `mtls.private_key`, `_bacnet_sc_config`, `_discovery_auth`) use `download://...`, triggering the `state` (`phase: "apply"`) → `download/blobset` → `state` (`phase: "final"`) flow. +* **`download://` URLs**: Secret-bearing blobs (`basic.password`, `_iot_endpoint_config`) use `download://...`, triggering the `state` (`phase: "apply"`) → `download/blobset` → `state` (`phase: "final"`) flow. + diff --git a/docs/specs/discovery.md b/docs/specs/discovery.md index 6ab7d8e850..e7fc0bcfcb 100644 --- a/docs/specs/discovery.md +++ b/docs/specs/discovery.md @@ -85,10 +85,10 @@ be reported: When a discovery scan produces bulk binary or text capture artifacts—such as network packet captures (`pcap` / `pcapng`) triggered via `config.discovery.families.`—the device streams the capture in chunks over `{topic_prefix}/upload/discovery` using the [`DiscoveryUpload`](../../schema/upload_discovery.json) ([_🧬schema_](../../gencode/docs/upload_discovery.html)) format rather than sending individual telemetry events. ### Chunk Framing & Sequence (`event_no`) -Each `upload/discovery` message is correlated with the active scan via `generation` and sequenced using `event_no`: -* **`event_no = 0` (Start Marker)**: Announces the start of the upload stream (`format`, `generation`). +Each `upload/discovery` message is correlated with the active scan via `(family, generation)` and sequenced using `event_no`: +* **`event_no = 0` (Start Marker)**: Announces the start of the upload stream (`family`, `generation`, `event_no = 0`). * **`event_no = 1 .. N` (Data Chunks)**: Sequential 1-based chunks carrying Base64-encoded binary data in `data`. * **`event_no = -(N + 1)` (Terminal EOF Marker)**: Mandatory final message where the negative value indicates the total chunk count `N` (`N = (-event_no) - 1`) and `sha256` provides the 64-character lowercase hex SHA-256 digest of the complete reassembled artifact. -In UDMIS, `UploadProcessor` stages incoming chunks in shared storage so out-of-order chunks distributed across multiple stateless pods are reassembled and cryptographically verified against `sha256` once all `1..N` chunks and the terminal `-(N + 1)` marker have arrived. +In UDMIS, `UploadProcessor` stages incoming chunks in shared storage keyed by `(family, generation)` so out-of-order chunks distributed across multiple stateless pods are reassembled and cryptographically verified against `sha256` once the start marker (`event_no = 0`), all `1..N` chunks, and the terminal `-(N + 1)` marker have arrived. diff --git a/docs/tools/spotter.md b/docs/tools/spotter.md index 88c89405a0..7443a291ed 100644 --- a/docs/tools/spotter.md +++ b/docs/tools/spotter.md @@ -62,14 +62,14 @@ Spotter processes diagnostic packet capture triggers sent declaratively over the 2. **Streaming MQTT Egress Transport**: - **Zero-Disk Streaming**: Packets are buffered dynamically in volatile memory (RAM) and sequentially published as reliable base64 chunks (`DiscoveryUpload`) over the streaming MQTT upload topic (`upload/discovery`), terminated by a SHA-256 EOF marker. - **Zero Secret Distribution**: Leverages the existing mTLS hardware key/certificate connection directly, avoiding external network credentials or outbound HTTP rules at the edge. -3. **Ad-hoc PCAP Reassembly ([bin/reassemble_pcap](../../edge/spotter/bin/reassemble_pcap))**: +3. **Ad-hoc Upload Reassembly ([bin/mapper](../../bin/mapper))**: Reassembles chunked `upload/discovery` messages (from JSON, JSONL, or `mosquitto_sub`) back into a valid `.pcap` binary capture file: ```bash # Reassemble upload chunks file into a pcap file: - ./edge/spotter/bin/reassemble_pcap stream_events.json capture.pcap + bin/mapper sites/udmi_site_model //mqtt/localhost DSN-1 reassemble stream_events.json capture.pcap # Or stream directly from mosquitto subscriber: - mosquitto_sub -h $BROKER -t '/r/+/d/+/upload/discovery' | ./edge/spotter/bin/reassemble_pcap - live.pcap + mosquitto_sub -h $BROKER -t '/r/+/d/+/upload/discovery' | bin/mapper sites/udmi_site_model //mqtt/localhost DSN-1 reassemble - live.pcap ``` --- @@ -133,7 +133,7 @@ Spotter includes a comprehensive suite of unit and integration tests located in ### Key Integration Tests - **`test_container`**: Validates container lifecycle isolation, volume mounting of runtime configs, clean signal termination, and exit code propagation inside Docker. -- **`test_pcap`**: Validates remote-triggered PCAP packet capture diagnostics over MQTT streaming and verifies binary reassembly via `reassemble_pcap`. +- **`test_pcap`**: Validates remote-triggered PCAP packet capture diagnostics over MQTT streaming and verifies binary reassembly via `bin/mapper`. - **`test_resource_contention`**: Validates process CPU, memory consumption, `/proc/PID/fd` file descriptor stability under load, and memory safety circuit breaker tripping. - **`test_fault_injection`**: Validates network fault tolerance, streaming MQTT backoff recovery, and socket reconnect logic under simulated broker resets. - **`test_parity`**: Runs co-existence integration testing against a simulated BACnet device on an isolated bridge network, confirming functional discovery parity with legacy discovery nodes. diff --git a/edge/spotter/GEMINI.md b/edge/spotter/GEMINI.md index e43c7fc5d8..1e13e1a1b0 100644 --- a/edge/spotter/GEMINI.md +++ b/edge/spotter/GEMINI.md @@ -31,7 +31,7 @@ All verification tests are categorised into **Unit Tests** and **Integration Tes - **Location**: [bin/](bin) & [tests/](tests) - **Executables**: - [test_container](bin/test_container): Validates container lifecycle isolation, volume mounting of configuration files, clean signal termination, and exit code propagation inside Docker. - - [test_pcap](bin/test_pcap): Validates remote-triggered PCAP packet capture diagnostics over MQTT streaming and verifies binary reassembly via [reassemble_pcap](bin/reassemble_pcap). + - [test_pcap](bin/test_pcap): Validates remote-triggered PCAP packet capture diagnostics over MQTT streaming and verifies binary reassembly via [bin/mapper](../../bin/mapper). - [test_parity](bin/test_parity): Runs co-existence integration testing against a simulated BACnet device on an isolated network, confirming functional parity with legacy discovery nodes. - [compare_field_parity](bin/compare_field_parity): Automated utility to run sequential discovery parity scans or diff event logs on live field testbeds. - [test_resource_contention](bin/test_resource_contention): Validates Spotter process CPU, memory consumption, file descriptor limits, and telemetry latency under concurrent heavy workloads. diff --git a/edge/spotter/README.md b/edge/spotter/README.md index 9127c0530d..8c98e0d4ed 100644 --- a/edge/spotter/README.md +++ b/edge/spotter/README.md @@ -80,14 +80,14 @@ Spotter processes diagnostic packet capture triggers sent declaratively over the 2. **Streaming MQTT Egress Transport**: - **Zero-Disk Streaming**: Packets are buffered dynamically in volatile memory (RAM) and sequentially published as reliable base64 chunks (`DiscoveryUpload`) over the streaming MQTT upload topic (`upload/discovery`), terminated by a SHA-256 EOF marker. - **Zero Secret Distribution**: Leverages the existing mTLS hardware key/certificate connection directly, avoiding external network credentials or outbound HTTP rules at the edge. -3. **Ad-hoc PCAP Reassembly ([bin/reassemble_pcap](bin/reassemble_pcap))**: +3. **Ad-hoc Upload Reassembly ([bin/mapper](../../bin/mapper))**: Reassembles chunked `upload/discovery` messages (from JSON, JSONL, or `mosquitto_sub`) back into a valid `.pcap` binary capture file: ```bash # Reassemble upload chunks file into a pcap file: - ./edge/spotter/bin/reassemble_pcap stream_events.json capture.pcap + bin/mapper sites/udmi_site_model //mqtt/localhost DSN-1 reassemble stream_events.json capture.pcap # Or stream directly from mosquitto subscriber: - mosquitto_sub -h $BROKER -t '/r/+/d/+/upload/discovery' | ./edge/spotter/bin/reassemble_pcap - live.pcap + mosquitto_sub -h $BROKER -t '/r/+/d/+/upload/discovery' | bin/mapper sites/udmi_site_model //mqtt/localhost DSN-1 reassemble - live.pcap ``` --- @@ -98,7 +98,6 @@ Spotter processes diagnostic packet capture triggers sent declaratively over the | :--- | :--- | | **[bin/spotter](../../bin/spotter)** | Unified CLI orchestrator for starting (local/container) and stopping instances | | **[bin/run_spotter_tests](bin/run_spotter_tests)** | Unified verification test runner for unit and integration suites | -| **[bin/reassemble_pcap](bin/reassemble_pcap)** | Ad-hoc CLI utility to reassemble `upload/discovery` chunks into `.pcap` files | | **[bin/compare_field_parity](bin/compare_field_parity)** | Automated CLI tool to verify functional parity between legacy node and Spotter | | **[src/agent.py](src/agent.py)** | Main Spotter agent entry point, manager wiring, and command dispatcher | | **[src/providers/](src/providers/)** | Modular protocol discovery providers (BACnet, Ether, Passive) | @@ -165,7 +164,7 @@ Spotter includes a comprehensive suite of unit and integration tests located in ### Key Integration Tests - **`test_container`**: Validates container lifecycle isolation, volume mounting of runtime configs, clean signal termination, and exit code propagation inside Docker. -- **`test_pcap`**: Validates remote-triggered PCAP packet capture diagnostics over MQTT streaming and verifies binary reassembly via `reassemble_pcap`. +- **`test_pcap`**: Validates remote-triggered PCAP packet capture diagnostics over MQTT streaming and verifies binary reassembly via `bin/mapper`. - **`test_resource_contention`**: Validates process CPU, memory consumption, `/proc/PID/fd` file descriptor stability under load, and memory safety circuit breaker tripping. - **`test_fault_injection`**: Validates network fault tolerance, streaming MQTT backoff recovery, and socket reconnect logic under simulated broker resets. - **`test_parity`**: Runs co-existence integration testing against a simulated BACnet device on an isolated bridge network, confirming functional parity with legacy discovery nodes. diff --git a/edge/spotter/bin/reassemble_pcap b/edge/spotter/bin/reassemble_pcap deleted file mode 100755 index 3dc6636cf9..0000000000 --- a/edge/spotter/bin/reassemble_pcap +++ /dev/null @@ -1,165 +0,0 @@ -#!/usr/bin/env python3 -"""Utility to reassemble UDMI upload/discovery chunks into a valid PCAP file. - -Usage: - bin/reassemble_pcap - -Supported input formats: - - Raw JSON array of DiscoveryUpload: [ {"event_no": 1, "data": "..."}, ... ] - - JSON Lines (JSONL): One DiscoveryUpload JSON object per line - - mosquitto_sub output: " " - - Standard input (specify "-" as input file) -""" - -import argparse -import base64 -import hashlib -import json -import os -import sys - -PCAP_MAGIC_NUMBERS = [ - b"\xa1\xb2\xc3\xd4", # Standard pcap (same-endian) - b"\xd4\xc3\xb2\xa1", # Standard pcap (swapped-endian) - b"\xa1\xb2\x3c\x4d", # Nanosecond pcap (same-endian) - b"\x4d\x3c\xb2\xa1", # Nanosecond pcap (swapped-endian) -] - - -def parse_stream_events(raw_text: str): - """Parse DiscoveryUpload objects from JSON array, JSONL, or mosquitto_sub.""" - raw_text = raw_text.strip() - if not raw_text: - return [] - - # Case 1: Standard JSON array or single object - if raw_text.startswith("[") or raw_text.startswith("{"): - try: - data = json.loads(raw_text) - if isinstance(data, list): - return [item.get("payload", item) if (isinstance(item, dict) and "payload" in item and isinstance(item["payload"], dict)) else item for item in data] - if isinstance(data, dict): - if "payload" in data and isinstance(data["payload"], dict) and ("data" in data["payload"] or "sha256" in data["payload"]): - return [data["payload"]] - return [data] - except json.JSONDecodeError: - pass - - # Case 2: Line by line (JSONL or mosquitto_sub format: " ") - events = [] - for line in raw_text.splitlines(): - line = line.strip() - if not line: - continue - # Strip topic prefix if present - if " " in line and not line.startswith("{"): - _, _, payload = line.partition(" ") - else: - payload = line - - try: - obj = json.loads(payload) - if isinstance(obj, dict): - inner = obj.get("payload") if (isinstance(obj.get("payload"), dict) and ("data" in obj["payload"] or "sha256" in obj["payload"])) else obj - if "data" in inner or "sha256" in inner: - events.append(inner) - except json.JSONDecodeError: - continue - - return events - - -def reassemble_pcap(input_source: str, output_path: str) -> None: - """Reassembles chunked upload records into a binary pcap capture file.""" - if input_source == "-": - raw_content = sys.stdin.read() - else: - if not os.path.exists(input_source): - sys.exit(f"Error: Input file '{input_source}' does not exist.") - with open(input_source, "r", encoding="utf-8", errors="replace") as f: - raw_content = f.read() - - events = parse_stream_events(raw_content) - if not events: - sys.exit("Error: No valid DiscoveryUpload records with 'data' found in input.") - - # Filter out records missing data (such as start/EOF markers) - valid_events = [e for e in events if e.get("data")] - if not valid_events: - sys.exit("Error: Found upload records but none contained base64 'data'.") - - eof_events = [e for e in events if e.get("sha256")] - - # Sort primarily by chunk_index, fallback to event_no - def sort_key(e): - return ( - e.get("chunk_index", 0) - if e.get("chunk_index") is not None - else e.get("event_no", 0) - ) - - sorted_events = sorted(valid_events, key=sort_key) - session_id = sorted_events[0].get("session_id", "unknown") - total_chunks = len(sorted_events) - - # Reconstruct binary payload - pcap_data = bytearray() - for idx, evt in enumerate(sorted_events): - b64_str = evt["data"] - try: - chunk_bytes = base64.b64decode(b64_str) - pcap_data.extend(chunk_bytes) - except Exception as err: # pylint: disable=broad-exception-caught - sys.exit(f"Error: Failed to decode base64 chunk at index {idx}: {err}") - - # Validate output - if not pcap_data: - sys.exit("Error: Reassembled PCAP payload is empty (0 bytes).") - - if eof_events: - expected_sha256 = str(eof_events[-1]["sha256"]).lower() - actual_sha256 = hashlib.sha256(pcap_data).hexdigest().lower() - if actual_sha256 != expected_sha256: - sys.exit( - f"Error: SHA-256 mismatch (expected {expected_sha256}, got {actual_sha256})." - ) - - magic = bytes(pcap_data[:4]) - is_valid_magic = any(magic == m for m in PCAP_MAGIC_NUMBERS) - - # Write to destination file - os.makedirs(os.path.dirname(os.path.abspath(output_path)), exist_ok=True) - with open(output_path, "wb") as f: - f.write(pcap_data) - - magic_status = "VALID" if is_valid_magic else "WARNING: Unexpected header" - print("========================================") - print("PCAP Reassembly Completed Successfully") - print("========================================") - print(f"Session ID : {session_id}") - print(f"Chunks Decoded : {total_chunks}") - print(f"Total Bytes : {len(pcap_data)}") - print(f"Magic Bytes : {magic.hex()} ({magic_status})") - print(f"Output File : {output_path}") - - -def main(): - """CLI entry point for PCAP reassembly.""" - parser = argparse.ArgumentParser( - description="Reassemble UDMI stream events into PCAP file" - ) - parser.add_argument( - "input", - help="Input file containing stream event data (or '-' for stdin)", - ) - parser.add_argument( - "output", help="Destination file path for reassembled .pcap" - ) - args = parser.parse_args() - - reassemble_pcap(args.input, args.output) - - -if __name__ == "__main__": - main() - diff --git a/edge/spotter/bin/test_pcap b/edge/spotter/bin/test_pcap index d9cf2962fd..7456ac3d11 100755 --- a/edge/spotter/bin/test_pcap +++ b/edge/spotter/bin/test_pcap @@ -244,9 +244,8 @@ while [[ $SECONDS -lt $END_TIME ]]; do fi if grep -q '/upload/discovery' "$MQTT_OUT"; then - TOTAL=$(grep '/upload/discovery' "$MQTT_OUT" | head -n 1 | sed 's|^[^ ]* ||' | jq -r '.total_chunks') - COUNT=$(grep '/upload/discovery' "$MQTT_OUT" | wc -l) - if [[ -n "$TOTAL" && "$TOTAL" != "null" && $COUNT -ge $TOTAL ]]; then + EOF_EVENT=$(grep '/upload/discovery' "$MQTT_OUT" | sed 's|^[^ ]* ||' | jq -r 'select(.event_no < 0) | .event_no' | tail -n 1) + if [[ -n "$EOF_EVENT" ]]; then PCAP_DONE=1 fi fi @@ -276,9 +275,9 @@ if grep '/state' "$MQTT_OUT" | tail -n 1 | sed 's|^[^ ]* ||' | jq -e '.discovery exit 1 fi -echo "Reassembling pcap payload using reassemble_pcap utility" +echo "Reassembling pcap payload using bin/mapper reassemble" grep '/upload/discovery' "$MQTT_OUT" | sed 's|^[^ ]* ||' > "$TEST_DIR/stream_events.json" -"${SCRIPT_DIR}/reassemble_pcap" "$TEST_DIR/stream_events.json" "$TEST_DIR/assembled.pcap" +"$WORKSPACE_DIR/bin/mapper" "$WORKSPACE_DIR/sites/udmi_site_model" //mqtt/localhost DSN-1 reassemble "$TEST_DIR/stream_events.json" "$TEST_DIR/assembled.pcap" echo "Validating pcap payload size" SIZE=$(stat -c%s "$TEST_DIR/assembled.pcap") diff --git a/edge/spotter/src/manager/discovery.py b/edge/spotter/src/manager/discovery.py index 5bb1625a91..b3341dbaa7 100644 --- a/edge/spotter/src/manager/discovery.py +++ b/edge/spotter/src/manager/discovery.py @@ -62,8 +62,8 @@ class SpotterDiscoveryManager(DiscoveryManager): """Unified Discovery Manager for Spotter. Extends standard DiscoveryManager to handle active protocol sweeps and - TRACE packet streaming over upload/discovery with safety circuit breaker checks - and UDMI discovery phase lifecycle tracking (pending/active/stopped). + TRACE packet streaming over upload/discovery with safety circuit breaker + checks and UDMI discovery phase lifecycle tracking (pending/active/stopped). """ def __init__(self, max_mem_pct: float = 85.0) -> None: @@ -492,7 +492,6 @@ def _run_trace_capture(self, family: str, fam_config: Any) -> None: total_chunks = ( (total_bytes + chunk_size - 1) // chunk_size if total_bytes > 0 else 1 ) - session_id = f"trace-{family}-{int(time.time())}" gen_str = f_state.generation or _format_generation( datetime.now(timezone.utc) ) @@ -502,9 +501,7 @@ def _run_trace_capture(self, family: str, fam_config: Any) -> None: version=UDMI_VERSION, family=family, generation=gen_str, - session_id=session_id, event_no=0, - total_chunks=total_chunks, ) self.publish_upload(start_upload, "discovery") @@ -520,10 +517,7 @@ def _run_trace_capture(self, family: str, fam_config: Any) -> None: version=UDMI_VERSION, family=family, generation=gen_str, - session_id=session_id, event_no=event_no, - chunk_index=idx, - total_chunks=total_chunks, data=b64_data, ) self.publish_upload(chunk_upload, "discovery") @@ -540,9 +534,7 @@ def _run_trace_capture(self, family: str, fam_config: Any) -> None: version=UDMI_VERSION, family=family, generation=gen_str, - session_id=session_id, event_no=-(total_chunks + 1), - total_chunks=total_chunks, sha256=hashlib.sha256(full_data).hexdigest(), ) self.publish_upload(eof_upload, "discovery") diff --git a/edge/spotter/tests/test_agent.py b/edge/spotter/tests/test_agent.py index 8469e0d343..4c17d30a16 100644 --- a/edge/spotter/tests/test_agent.py +++ b/edge/spotter/tests/test_agent.py @@ -460,20 +460,21 @@ def test_run_trace_worker_streaming(self, mock_capture): start_model = calls[0][0][1] self.assertEqual(start_model.event_no, 0) self.assertEqual(start_model.family, "ether") - self.assertIn("trace-ether-", start_model.session_id) + self.assertIsNotNone(start_model.generation) - # 2. Data chunk (event_no = 1, chunk_index = 0) + # 2. Data chunk (event_no = 1) self.assertEqual(calls[1][0][0], "upload/discovery") chunk_model = calls[1][0][1] self.assertEqual(chunk_model.event_no, 1) - self.assertEqual(chunk_model.chunk_index, 0) - self.assertEqual(chunk_model.total_chunks, 1) - self.assertEqual(chunk_model.session_id, start_model.session_id) + self.assertEqual(chunk_model.family, "ether") + self.assertEqual(chunk_model.generation, start_model.generation) # 3. Terminal EOF marker (event_no = -2, sha256) self.assertEqual(calls[2][0][0], "upload/discovery") eof_model = calls[2][0][1] self.assertEqual(eof_model.event_no, -2) + self.assertEqual(eof_model.family, "ether") + self.assertEqual(eof_model.generation, start_model.generation) self.assertEqual( eof_model.sha256, hashlib.sha256(b"MOCK_PACKET_HEADERMOCK_PACKET_BODY").hexdigest(), diff --git a/etc/sequencer.out b/etc/sequencer.out index 8d14ee33e1..2c15fa5ca9 100644 --- a/etc/sequencer.out +++ b/etc/sequencer.out @@ -6,6 +6,8 @@ RESULT pass discovery.scan scan_depth_entries+bacnet ALPHA 10/10 Sequence comple RESULT pass discovery.scan scan_depth_entries+vendor ALPHA 10/10 Sequence complete RESULT pass discovery.scan scan_depth_system+bacnet ALPHA 10/10 Sequence complete RESULT pass discovery.scan scan_depth_system+vendor ALPHA 10/10 Sequence complete +RESULT pass discovery.scan scan_depth_trace+bacnet ALPHA 10/10 Sequence complete +RESULT pass discovery.scan scan_depth_trace+vendor ALPHA 10/10 Sequence complete RESULT pass discovery.scan scan_network_single+bacnet ALPHA 10/10 Sequence complete RESULT pass discovery.scan scan_network_single+vendor ALPHA 10/10 Sequence complete RESULT pass discovery.scan scan_periodic_now_enumerate+bacnet PREVIEW 10/10 Sequence complete @@ -20,6 +22,7 @@ RESULT pass discovery.scan scan_single_targeted+bacnet ALPHA 10/10 Sequence comp RESULT pass discovery.scan scan_single_targeted+vendor ALPHA 10/10 Sequence complete RESULT pass endpoint.config endpoint_connection_bad_alternate PREVIEW 10/10 Sequence complete RESULT pass endpoint.config endpoint_connection_bad_hash PREVIEW 10/10 Sequence complete +RESULT pass endpoint.config endpoint_connection_download ALPHA 10/10 Sequence complete RESULT pass endpoint.config endpoint_connection_error PREVIEW 10/10 Sequence complete RESULT pass endpoint.config endpoint_connection_retry PREVIEW 10/10 Sequence complete RESULT pass endpoint.config endpoint_connection_success_alternate PREVIEW 10/10 Sequence complete @@ -51,6 +54,13 @@ RESULT pass system extra_config STABLE 10/10 Sequence complete RESULT pass system family_ether_addr PREVIEW 10/10 Sequence complete RESULT pass system family_ipv4_addr PREVIEW 10/10 Sequence complete RESULT skip system family_ipv6_addr PREVIEW 0/0 No ipv6 address defined in metadata +RESULT pass system state_make_model STABLE 10/10 Sequence complete +RESULT pass system state_software STABLE 10/10 Sequence complete +RESULT pass system system_last_update STABLE 11/11 Sequence complete +CPBLTY pass system system_last_update.subblocks ALPHA 1/1 Capability supported +RESULT pass system system_min_loglevel ALPHA 10/10 Sequence complete +RESULT pass system too_much_state ALPHA 10/10 Sequence complete +RESULT pass system valid_serial_no STABLE 10/10 Sequence complete RESULT pass system.mode system_mode_restart PREVIEW 10/10 Sequence complete RESULT pass system.software.updates blob_update_idempotency PREVIEW 10/10 Sequence complete RESULT pass system.software.updates blob_update_incompatible PREVIEW 10/10 Sequence complete @@ -59,13 +69,6 @@ RESULT pass system.software.updates blob_update_invalid_payload PREVIEW 10/10 Se RESULT pass system.software.updates blob_update_oversize PREVIEW 10/10 Sequence complete RESULT pass system.software.updates blob_update_success PREVIEW 10/10 Sequence complete RESULT pass system.software.updates blob_update_unreachable_url PREVIEW 10/10 Sequence complete -RESULT pass system state_make_model STABLE 10/10 Sequence complete -RESULT pass system state_software STABLE 10/10 Sequence complete -RESULT pass system system_last_update STABLE 11/11 Sequence complete -CPBLTY pass system system_last_update.subblocks ALPHA 1/1 Capability supported -RESULT pass system system_min_loglevel ALPHA 10/10 Sequence complete -RESULT pass system too_much_state ALPHA 10/10 Sequence complete -RESULT pass system valid_serial_no STABLE 10/10 Sequence complete RESULT pass writeback writeback_failure ALPHA 10/10 Sequence complete RESULT pass writeback writeback_invalid ALPHA 10/10 Sequence complete RESULT pass writeback writeback_operation ALPHA 10/10 Sequence complete diff --git a/gencode/docs/config.html b/gencode/docs/config.html index d0d6324fb3..8378828bfd 100644 --- a/gencode/docs/config.html +++ b/gencode/docs/config.html @@ -2698,7 +2698,7 @@

Must be one of:

-
  • "_iot_endpoint_config"
  • "_bacnet_sc_config"
  • "_discovery_auth"
+
  • "_iot_endpoint_config"
diff --git a/gencode/docs/configuration_endpoint.html b/gencode/docs/configuration_endpoint.html index cd3dae2418..5f74375004 100644 --- a/gencode/docs/configuration_endpoint.html +++ b/gencode/docs/configuration_endpoint.html @@ -1016,238 +1016,6 @@

-

-
-
-
-
-
-
-

- -

-
- -
-
- - Type: string
-

Pre-signed JWT token or credential string

-
- - - - - - -
-
-
-
-
-
-
-
-
-
-
-

- -

-
- -
-
- - Type: object
- No Additional Properties - - - - - - -
-
-
-

- -

-
- -
-
- - Type: string
-

PEM-encoded private key material for mTLS authentication

-
- - - - - - -
-
-
-
-
-
-
-

- -

-
- -
-
- - Type: string
-

PEM-encoded client certificate for mTLS authentication

-
- - - - - - -
-
-
-
-
-
-
-

- -

-
- -
-
- - Type: string
-

PEM-encoded CA certificate bundle for peer verification

-
- - - - - -
diff --git a/gencode/docs/configuration_execution.html b/gencode/docs/configuration_execution.html index 6750ae0e3c..21ac84905b 100644 --- a/gencode/docs/configuration_execution.html +++ b/gencode/docs/configuration_execution.html @@ -1678,273 +1678,6 @@

-

-
-
-
-
-
-
-

- -

-
- -
-
- - Type: string
-

Pre-signed JWT token or credential string

-
- - - - - - -
-
-
-
-
-
-
-
-
-
-
-

- -

-
- -
-
- - Type: object
- No Additional Properties - - - - - - -
-
-
-

- -

-
- -
-
- - Type: string
-

PEM-encoded private key material for mTLS authentication

-
- - - - - - -
-
-
-
-
-
-
-

- -

-
- -
-
- - Type: string
-

PEM-encoded client certificate for mTLS authentication

-
- - - - - - -
-
-
-
-
-
-
-

- -

-
- -
-
- - Type: string
-

PEM-encoded CA certificate bundle for peer verification

-
- - - - - -
@@ -3389,273 +3122,6 @@

-

-
-
-
-
-
-
-

- -

-
- -
-
- - Type: string
-

Pre-signed JWT token or credential string

-
- - - - - - -
-
-
-
-
-
-
-
-
-
-
-

- -

-
- -
-
- - Type: object
- No Additional Properties - - - - - - -
-
-
-

- -

-
- -
-
- - Type: string
-

PEM-encoded private key material for mTLS authentication

-
- - - - - - -
-
-
-
-
-
-
-

- -

-
- -
-
- - Type: string
-

PEM-encoded client certificate for mTLS authentication

-
- - - - - - -
-
-
-
-
-
-
-

- -

-
- -
-
- - Type: string
-

PEM-encoded CA certificate bundle for peer verification

-
- - - - - -
diff --git a/gencode/docs/configuration_pod.html b/gencode/docs/configuration_pod.html index ffe62f5509..397a283919 100644 --- a/gencode/docs/configuration_pod.html +++ b/gencode/docs/configuration_pod.html @@ -1436,273 +1436,6 @@

-

-
-
-
-
-
-
-

- -

-
- -
-
- - Type: string
-

Pre-signed JWT token or credential string

-
- - - - - - -
-
-
-
-
-
-
-
-
-
-
-

- -

-
- -
-
- - Type: object
- No Additional Properties - - - - - - -
-
-
-

- -

-
- -
-
- - Type: string
-

PEM-encoded private key material for mTLS authentication

-
- - - - - - -
-
-
-
-
-
-
-

- -

-
- -
-
- - Type: string
-

PEM-encoded client certificate for mTLS authentication

-
- - - - - - -
-
-
-
-
-
-
-

- -

-
- -
-
- - Type: string
-

PEM-encoded CA certificate bundle for peer verification

-
- - - - - -
@@ -3388,18 +3121,26 @@

-
+
+
+
+
+
+
+
+
+
-
+

- +

-
+
Type: string
-

Pre-signed JWT token or credential string

+ ca_file
Type: string
+

Path to the CA certificate file

@@ -3450,303 +3177,7 @@

-
-
-
-
-
-
-
-

- -

-
- -
-
- - Type: object
- No Additional Properties - - - - - - -
-
-
-

- -

-
- -
-
- - Type: string
-

PEM-encoded private key material for mTLS authentication

-
- - - - - - -
-
-
-
-
-
-
-

- -

-
- -
-
- - Type: string
-

PEM-encoded client certificate for mTLS authentication

-
- - - - - - -
-
-
-
-
-
-
-

- -

-
- -
-
- - Type: string
-

PEM-encoded CA certificate bundle for peer verification

-
- - - - - - -
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-

- -

-
- -
-
- - Type: string
-

Path to the CA certificate file

-
- - - - - - -
-
-
-
-
+

@@ -5406,308 +4837,6 @@

-

-
-
-
-
-
-
-

- -

-
- -
-
- - Type: string
-

Pre-signed JWT token or credential string

-
- - - - - - -
-
-
-
-
-
-
-
-
-
-
-

- -

-
- -
-
- - Type: object
- No Additional Properties - - - - - - -
-
-
-

- -

-
- -
-
- - Type: string
-

PEM-encoded private key material for mTLS authentication

-
- - - - - - -
-
-
-
-
-
-
-

- -

-
- -
-
- - Type: string
-

PEM-encoded client certificate for mTLS authentication

-
- - - - - - -
-
-
-
-
-
-
-

- -

-
- -
-
- - Type: string
-

PEM-encoded CA certificate bundle for peer verification

-
- - - - - -
@@ -7720,630 +6849,6 @@

-
-
-
-
-

- -

-
- -
-
- - Type: string
-

Pre-signed JWT token or credential string

-
- - - - - - -
-
-
-
-
-
-
-
-
-
-
-

- -

-
- -
-
- - Type: object
- No Additional Properties - - - - - - -
-
-
-

- -

-
- -
-
- - Type: string
-

PEM-encoded private key material for mTLS authentication

-
- - - - - - -
-
-
-
-
-
-
-

- -

-
- -
-
- - Type: string
-

PEM-encoded client certificate for mTLS authentication

-
- - - - - - -
-
-
-
-
-
-
-

- -

-
- -
-
- - Type: string
-

PEM-encoded CA certificate bundle for peer verification

-
- - - - - - -
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-

- -

-
- -
-
- - Type: string
-

Path to the CA certificate file

-
- - - - - - -
-
-
-
-
-
-
-

- -

-
- -
-
- - Type: string
-

Path to the client certificate file

-
- - - - - - -
-
-
-
-
-
-
-

- -

-
- -
-
- - Type: string
-

Path to the client private key file

-
- - - - - - -
-
-
-
-
-
-
-

- -

-
- -
-
- - Type: stringFormat: date-time
-

The timestamp of the endpoint generation

-
- - - - - -
-
Example:
-
"2019-01-17T14:02:29.364Z"
-
-
-
-
-
-
-
-
-
-

- -

-
- -
-
- - Type: stringFormat: date-time
-

Timestamp when the provisioned credential or endpoint expires

-
- - - - - -
-
Example:
-
"2019-01-17T14:02:29.364Z"
-
@@ -8353,18 +6858,18 @@

-
+
-
+

- +

-
+

Endpoint Configuration

Type: object
-

Parameters to define a message endpoint

+ from + + + + ca_file
Type: string
+

Path to the CA certificate file

- - No Additional Properties -
+
+
+
+
+
-
+

- +

-
+
Type: string
-

Friendly name for this flow (debugging and diagnostics)

+ cert_file
Type: string
+

Path to the client certificate file

@@ -8454,18 +6968,18 @@

-
+
-
+

- +

-
+
Type: enum (of string)
-
-

Must be one of:

-
  • "local"
  • "pubsub"
  • "file"
  • "trace"
  • "mqtt"
-
+ key_file
Type: string
+

Path to the client private key file

+
@@ -8511,18 +7023,18 @@

Must be one of:

-
+
-
+

- +

-
+
Type: enum (of string)
-
-

Must be one of:

-
  • "ssl"
  • "tcp"
-
+ generation
Type: stringFormat: date-time
+

The timestamp of the endpoint generation

+
- +
+
Example:
+
"2019-01-17T14:02:29.364Z"
+
+
-
+
-
+

- +

-
+
Type: string
- + expiry
Type: stringFormat: date-time
+

Timestamp when the provisioned credential or endpoint expires

+
- +
+
Example:
+
"2019-01-17T14:02:29.364Z"
+
+
-
+
+
+
+
+
-
+

- +

-
+
Type: string
-

Simple payload template for simple injection use cases

+ morf

Endpoint Configuration

Type: object
+

Parameters to define a message endpoint

+ + No Additional Properties -
-
-
-
-
+
-
+

- +

-
+
Type: string
-

Error message container for capturing errors during parsing/handling

+ name
Type: string
+

Friendly name for this flow (debugging and diagnostics)

@@ -8732,18 +7246,18 @@

-
+
-
+

- +

-
+
Type: integer
- + protocol
Type: enum (of string)
+
+

Must be one of:

+
  • "local"
  • "pubsub"
  • "file"
  • "trace"
  • "mqtt"
+
@@ -8786,18 +7303,18 @@

-
+
-
+

- +

-
+
Type: integer
-

Delay waiting for config message on start, 0 for default, <0 to disable

-
+ transport
Type: enum (of string)
+
+

Must be one of:

+
  • "ssl"
  • "tcp"
+
@@ -8841,18 +7360,18 @@

-
+
-
+

- +

-
+
Type: string
+ hostname
Type: string
@@ -8895,18 +7414,18 @@

-
+
-
+

- +

-
+
Type: string
-

Prefix for message topics

-
Must match regular expression: ^[-_/a-zA-Z0-9]+$ + payload
Type: string
+

Simple payload template for simple injection use cases

+
@@ -8950,18 +7469,18 @@

-
+
-
+

- +

-
+
Type: string
-

Id for the receiving message channel

-
Must match regular expression: ^[-_/a-zA-Z0-9#]+$ + error
Type: string
+

Error message container for capturing errors during parsing/handling

+
@@ -9005,18 +7524,18 @@

-
+
-
+

- +

-
+
Type: string
-

Id for the sending messages channel

-
Must match regular expression: ^[-_/a-zA-Z0-9#]+$ + port
Type: integer
+ @@ -9060,18 +7578,18 @@

-
+
-
+

- +

-
+
Type: string
-

Id for a side-car message channel

-
Must match regular expression: ^[-_/a-zA-Z0-9#]+$ + config_sync_sec
Type: integer
+

Delay waiting for config message on start, 0 for default, <0 to disable

+
@@ -9115,18 +7633,18 @@

-
+
-
+

- +

-
+
Type: string
+ client_id
Type: string
@@ -9169,18 +7687,18 @@

-
+
-
+

- +

-
+
Type: string
- + topic_prefix
Type: string
+

Prefix for message topics

+
Must match regular expression: ^[-_/a-zA-Z0-9]+$ @@ -9223,18 +7742,18 @@

-
+
-
+

- +

-
+
Type: string
-

Indicator if this endpoint should be active (null or non-empty)

-
+ recv_id
Type: string
+

Id for the receiving message channel

+
Must match regular expression: ^[-_/a-zA-Z0-9#]+$ @@ -9278,18 +7797,18 @@

-
+
-
+

- +

-
+
Type: boolean
-

True if config messages should not be acked (lower QOS)

-
+ send_id
Type: string
+

Id for the sending messages channel

+
Must match regular expression: ^[-_/a-zA-Z0-9#]+$ @@ -9333,18 +7852,18 @@

-
+
-
+

- +

-
+
Type: integer
-

Queue capacity for limiting pipes

-
+ side_id
Type: string
+

Id for a side-car message channel

+
Must match regular expression: ^[-_/a-zA-Z0-9#]+$ @@ -9388,18 +7907,18 @@

-
+
-
+

- +

-
+
Type: integer
-

Artifical publish delay for testing

-
+ gatewayId
Type: string
+ @@ -9443,18 +7961,18 @@

-
+
-
+

- +

-
+
Type: integer
-

Rate for periodic task execution

-
+ deviceId
Type: string
+ @@ -9498,18 +8015,18 @@

-
+
-
+

- +

-
+
Type: object
- + enabled
Type: string
+

Indicator if this endpoint should be active (null or non-empty)

+
@@ -9552,18 +8070,18 @@

-
+
-
+

- +

-
+
Type: string
- + noConfigAck
Type: boolean
+

True if config messages should not be acked (lower QOS)

+
@@ -9606,18 +8125,18 @@

-
+
-
+

- +

-
+
Type: object
- No Additional Properties + capacity
Type: integer
+

Queue capacity for limiting pipes

+
-
+
+
+
+
+
-
+

- +

-
+
Type: object
- No Additional Properties + publish_delay_sec
Type: integer
+

Artifical publish delay for testing

+
-
+
+
+
+
+
-
+

- +

-
+
Type: string
- + periodic_sec
Type: integer
+

Rate for periodic task execution

+
@@ -9781,18 +8290,18 @@

-
+
-
+

- +

-
+
Type: string
+ keyBytes
Type: object
@@ -9849,22 +8344,18 @@

-
-
-
-
-
+
-
+

- +

-
+
Type: object
- No Additional Properties + algorithm
Type: string
+ -
+
+
+
+
+
-
+

- +

-
+
Type: string
- + auth_provider
Type: object
+ No Additional Properties -
-
-
-
-
+
-
+

- +

-
+
Type: string
-

Pre-signed JWT token or credential string

-
+ basic
Type: object
+ No Additional Properties -
-
-
-
-
-
-
-
-
+
-
+

- +

-
+
Type: object
- No Additional Properties + basic + + + + username
Type: string
+ -
+
+
+
+
+
-
+

- +

-
+
Type: string
-

PEM-encoded private key material for mTLS authentication

-
+ password
Type: string
+ @@ -10177,18 +8641,22 @@

-
+
+
+
+
+
-
+

- +

-
+
Type: string
-

PEM-encoded client certificate for mTLS authentication

-
- - - + jwt
Type: object
+ No Additional Properties -
-
-
-
-
+ + + +
-
+

- +

-
+
Type: string
-

PEM-encoded CA certificate bundle for peer verification

-
+ audience
Type: string
+ @@ -10663,285 +9118,45 @@

All properties whose name matches the following regular expression must respect the following conditions

- Property name regular expression: ^[a-z]+$ -
- -

Iot Access

Type: object
- - - No Additional Properties - - - - - - -
-
-
-

- -

-
- -
-
- - Type: string
- - - - - - - -
-
-
-
-
-
-
-

- -

-
- -
-
- -

Iot Provider

Type: enum (of string)
- - -
-

Must be one of:

-
  • "local"
  • "dynamic"
  • "implicit"
  • "zanzara"
  • "pubsub"
  • "mqtt"
  • "gbos"
  • "gref"
  • "etcd"
  • "jwt"
  • "clearblade"
-
- - - - - - -
-
-
-
-
-
-
-

- -

-
- -
-
- - Type: string
- - - - - - - -
-
-
-
-
-
-
-

- -

-
- -
-
- - Type: integer
- - - - - - - -
-
-
-
-
-
-
-

- -

-
- -
-
- -

Iot Access

Type: object
+ + No Additional Properties -
-
-
-
-
+
-
+

- +

-
+

Endpoint Configuration

Type: object
-

Parameters to define a message endpoint

-
+ name
Type: string
- No Additional Properties -
+
+
+
+
+
-
+

- +

-
+
Type: string
-

Friendly name for this flow (debugging and diagnostics)

-
+ provider

Iot Provider

Type: enum (of string)
+ + +
+

Must be one of:

+
  • "local"
  • "dynamic"
  • "implicit"
  • "zanzara"
  • "pubsub"
  • "mqtt"
  • "gbos"
  • "gref"
  • "etcd"
  • "jwt"
  • "clearblade"
+
@@ -11031,18 +9244,18 @@

-
+
-
+

- +

-
+
Type: enum (of string)
-
-

Must be one of:

-
  • "local"
  • "pubsub"
  • "file"
  • "trace"
  • "mqtt"
-
+ project_id
Type: string
+ @@ -11088,18 +9291,18 @@

Must be one of:

-
+
-
+

- +

-
+
Type: enum (of string)
-
-

Must be one of:

-
  • "ssl"
  • "tcp"
-
+ profile_sec
Type: integer
+ @@ -11145,18 +9338,18 @@

Must be one of:

-
+
-
+

- +

-
+
Type: string
+ options
Type: string
@@ -11199,18 +9385,18 @@

-
+
-
+

- +

-
+
Type: string
-

Simple payload template for simple injection use cases

+ endpoint

Endpoint Configuration

Type: object
+

Parameters to define a message endpoint

+ + No Additional Properties -
-
-
-
-
+
-
+

- +

-
+
Type: string
-

Error message container for capturing errors during parsing/handling

+ name
Type: string
+

Friendly name for this flow (debugging and diagnostics)

@@ -11309,18 +9486,18 @@

-
+
-
+

- +

-
+
Type: integer
- + protocol
Type: enum (of string)
+
+

Must be one of:

+
  • "local"
  • "pubsub"
  • "file"
  • "trace"
  • "mqtt"
+
@@ -11363,18 +9543,18 @@

-
+
-
+

- +

-
+
Type: integer
-

Delay waiting for config message on start, 0 for default, <0 to disable

-
+ transport
Type: enum (of string)
+
+

Must be one of:

+
  • "ssl"
  • "tcp"
+
@@ -11418,18 +9600,18 @@

-
+
-
+

- +

-
+
Type: string
+ hostname
Type: string
@@ -11472,18 +9654,18 @@

-
+
-
+

- +

-
+
Type: string
-

Prefix for message topics

-
Must match regular expression: ^[-_/a-zA-Z0-9]+$ + payload
Type: string
+

Simple payload template for simple injection use cases

+
@@ -11527,18 +9709,18 @@

-
+
-
+

- +

-
+
Type: string
-

Id for the receiving message channel

-
Must match regular expression: ^[-_/a-zA-Z0-9#]+$ + error
Type: string
+

Error message container for capturing errors during parsing/handling

+
@@ -11582,18 +9764,18 @@

-
+
-
+

- +

-
+
Type: string
-

Id for the sending messages channel

-
Must match regular expression: ^[-_/a-zA-Z0-9#]+$ + port
Type: integer
+ @@ -11637,18 +9818,18 @@

-
+
-
+

- +

-
+
Type: string
-

Id for a side-car message channel

-
Must match regular expression: ^[-_/a-zA-Z0-9#]+$ + config_sync_sec
Type: integer
+

Delay waiting for config message on start, 0 for default, <0 to disable

+
@@ -11692,18 +9873,18 @@

-
+
-
+

- +

-
+
Type: string
+ client_id
Type: string
@@ -11746,18 +9927,18 @@

-
+
-
+

- +

-
+
Type: string
- + topic_prefix
Type: string
+

Prefix for message topics

+
Must match regular expression: ^[-_/a-zA-Z0-9]+$ @@ -11800,18 +9982,18 @@

-
+
-
+

- +

-
+
Type: string
-

Indicator if this endpoint should be active (null or non-empty)

-
+ recv_id
Type: string
+

Id for the receiving message channel

+
Must match regular expression: ^[-_/a-zA-Z0-9#]+$ @@ -11855,18 +10037,18 @@

-
+
-
+

- +

-
+
Type: boolean
-

True if config messages should not be acked (lower QOS)

-
+ send_id
Type: string
+

Id for the sending messages channel

+
Must match regular expression: ^[-_/a-zA-Z0-9#]+$ @@ -11910,18 +10092,18 @@

-
+
-
+

- +

-
+
Type: integer
-

Queue capacity for limiting pipes

-
+ side_id
Type: string
+

Id for a side-car message channel

+
Must match regular expression: ^[-_/a-zA-Z0-9#]+$ @@ -11965,18 +10147,18 @@

-
+
-
+

- +

-
+
Type: integer
-

Artifical publish delay for testing

-
+ gatewayId
Type: string
+ @@ -12020,18 +10201,18 @@

-
+
-
+

- +

-
+
Type: integer
-

Rate for periodic task execution

-
+ deviceId
Type: string
+ @@ -12075,18 +10255,18 @@

-
+
-
+

- +

-
+
Type: object
- + enabled
Type: string
+

Indicator if this endpoint should be active (null or non-empty)

+
@@ -12129,18 +10310,18 @@

-
+
-
+

- +

-
+
Type: string
- + noConfigAck
Type: boolean
+

True if config messages should not be acked (lower QOS)

+
@@ -12183,18 +10365,18 @@

-
+
-
-

- +
+

+

-
+
Type: object
- No Additional Properties + capacity
Type: integer
+

Queue capacity for limiting pipes

+
-
+
+
+
+

+
-
+

- +

-
+
Type: object
- No Additional Properties + publish_delay_sec
Type: integer
+

Artifical publish delay for testing

+
-
+
+
+
+
+
-
+

- +

-
+
Type: string
- + periodic_sec
Type: integer
+

Rate for periodic task execution

+
@@ -12358,18 +10530,18 @@

-
+
-
+

- +

-
+
Type: string
+ keyBytes
Type: object
@@ -12426,22 +10584,18 @@

-
-
-
-
-
+
-
+

- +

-
+
Type: object
- No Additional Properties + algorithm
Type: string
+ -
+
+
+
+
+
-
+

- +

-
+
Type: string
- + auth_provider
Type: object
+ No Additional Properties -
-
-
-
-
+
-
+

- +

-
+
Type: string
-

Pre-signed JWT token or credential string

-
+ basic
Type: object
+ No Additional Properties -
-
-
-
-
-
-
-
-
+
-
+

- +

-
+
Type: object
- No Additional Properties + basic + + + + username
Type: string
+ -
+
+
+
+
+
-
+

- +

-
+
Type: string
-

PEM-encoded private key material for mTLS authentication

-
+ password
Type: string
+ @@ -12754,18 +10881,22 @@

-
+
+
+
+
+
-
+

- +

-
+
Type: string
-

PEM-encoded client certificate for mTLS authentication

-
+ jwt
Type: object
+ No Additional Properties -
-
-
-
-
+
-
+

- +

-
+
Type: string
-

PEM-encoded CA certificate bundle for peer verification

-
+ audience
Type: string
+ @@ -13271,254 +11389,14 @@

- -

-
- -
-
- - Type: string
- - - - - - - -
-
-
-

-
-
-
-

- -

-
- -
-
- -

Iot Provider

Type: enum (of string)
- - -
-

Must be one of:

-
  • "local"
  • "dynamic"
  • "implicit"
  • "zanzara"
  • "pubsub"
  • "mqtt"
  • "gbos"
  • "gref"
  • "etcd"
  • "jwt"
  • "clearblade"
-
- - - - - - -
-
-
-
-
-
-
-

- -

-
- -
-
- - Type: string
- - - - - - - -
-
-
-
-
-
-
-

- -

-
- -
-
- - Type: integer
- - - - - - - -
-
-
-
-
-
-
-

- -

-
- -
-
- - Type: string
- - - - - - - -
-
-
-
-
-
-
-

- +

-
+

Endpoint Configuration

Type: object
-

Parameters to define a message endpoint

-
+ name
Type: string
- No Additional Properties -
+
+
+
+
+
-
+

- +

-
+
Type: string
-

Friendly name for this flow (debugging and diagnostics)

-
+ provider

Iot Provider

Type: enum (of string)
+ + +
+

Must be one of:

+
  • "local"
  • "dynamic"
  • "implicit"
  • "zanzara"
  • "pubsub"
  • "mqtt"
  • "gbos"
  • "gref"
  • "etcd"
  • "jwt"
  • "clearblade"
+
@@ -13608,18 +11484,18 @@

-
+
-
+

- +

-
+
Type: enum (of string)
-
-

Must be one of:

-
  • "local"
  • "pubsub"
  • "file"
  • "trace"
  • "mqtt"
-
+ project_id
Type: string
+ @@ -13665,18 +11531,18 @@

Must be one of:

-
+
-
+

- +

-
+
Type: enum (of string)
-
-

Must be one of:

-
  • "ssl"
  • "tcp"
-
+ profile_sec
Type: integer
+ @@ -13722,18 +11578,18 @@

Must be one of:

-
+
-
+

- +

-
+
Type: string
+ options
Type: string
@@ -13776,18 +11625,18 @@

-
+
-
+

- +

-
+
Type: string
-

Simple payload template for simple injection use cases

+ endpoint

Endpoint Configuration

Type: object
+

Parameters to define a message endpoint

+ + No Additional Properties -
-
-
-
-
+
-
+

- +

-
+
Type: string
-

Error message container for capturing errors during parsing/handling

+ name
Type: string
+

Friendly name for this flow (debugging and diagnostics)

@@ -13886,18 +11726,18 @@

-
+
-
+

- +

-
+
Type: integer
- + protocol
Type: enum (of string)
+
+

Must be one of:

+
  • "local"
  • "pubsub"
  • "file"
  • "trace"
  • "mqtt"
+
@@ -13940,18 +11783,18 @@

-
+
-
+

- +

-
+
Type: integer
-

Delay waiting for config message on start, 0 for default, <0 to disable

-
+ transport
Type: enum (of string)
+
+

Must be one of:

+
  • "ssl"
  • "tcp"
+
@@ -13995,18 +11840,18 @@

-
+
-
+

- +

-
+
Type: string
+ hostname
Type: string
@@ -14049,18 +11894,18 @@

-
+
-
+

- +

-
+
Type: string
-

Prefix for message topics

-
Must match regular expression: ^[-_/a-zA-Z0-9]+$ + /> + + payload
Type: string
+

Simple payload template for simple injection use cases

+
@@ -14104,18 +11949,18 @@

-
+
-
+

- +

-
+
Type: string
-

Id for the receiving message channel

-
Must match regular expression: ^[-_/a-zA-Z0-9#]+$ + error
Type: string
+

Error message container for capturing errors during parsing/handling

+
@@ -14159,18 +12004,18 @@

-
+
-
+

- +

-
+
Type: string
-

Id for the sending messages channel

-
Must match regular expression: ^[-_/a-zA-Z0-9#]+$ + port
Type: integer
+ @@ -14214,18 +12058,18 @@

-
+
-
+

- +

-
+
Type: string
-

Id for a side-car message channel

-
Must match regular expression: ^[-_/a-zA-Z0-9#]+$ + config_sync_sec
Type: integer
+

Delay waiting for config message on start, 0 for default, <0 to disable

+
@@ -14269,18 +12113,18 @@

-
+
-
+

- +

-
+
Type: string
+ client_id
Type: string
@@ -14323,18 +12167,18 @@

-
+
-
+

- +

-
+
Type: string
- + topic_prefix
Type: string
+

Prefix for message topics

+
Must match regular expression: ^[-_/a-zA-Z0-9]+$ @@ -14377,18 +12222,18 @@

-
+
-
+

- +

-
+
Type: string
-

Indicator if this endpoint should be active (null or non-empty)

-
+ recv_id
Type: string
+

Id for the receiving message channel

+
Must match regular expression: ^[-_/a-zA-Z0-9#]+$ @@ -14432,18 +12277,18 @@

-
+
-
+

- +

-
+
Type: boolean
-

True if config messages should not be acked (lower QOS)

-
+ send_id
Type: string
+

Id for the sending messages channel

+
Must match regular expression: ^[-_/a-zA-Z0-9#]+$ @@ -14487,18 +12332,18 @@

-
+
-
+

- +

-
+
Type: integer
-

Queue capacity for limiting pipes

-
+ side_id
Type: string
+

Id for a side-car message channel

+
Must match regular expression: ^[-_/a-zA-Z0-9#]+$ @@ -14542,18 +12387,18 @@

-
+
-
+

- +

-
+
Type: integer
-

Artifical publish delay for testing

-
+ gatewayId
Type: string
+ @@ -14597,18 +12441,18 @@

-
+
-
+

- +

-
+
Type: integer
-

Rate for periodic task execution

-
+ deviceId
Type: string
+ @@ -14652,18 +12495,18 @@

-
+
-
+

- +

-
+
Type: object
- + enabled
Type: string
+

Indicator if this endpoint should be active (null or non-empty)

+
@@ -14706,18 +12550,18 @@

-
+
-
+

- +

-
+
Type: string
- + noConfigAck
Type: boolean
+

True if config messages should not be acked (lower QOS)

+
@@ -14760,18 +12605,18 @@

-
+
-
+

- +

-
+
Type: object
- No Additional Properties + capacity
Type: integer
+

Queue capacity for limiting pipes

+
-
+
+
+
+
+
-
+

- +

-
+
Type: object
- No Additional Properties + publish_delay_sec
Type: integer
+

Artifical publish delay for testing

+
-
+
+
+
+
+
-
+

- +

-
+
Type: string
- + periodic_sec
Type: integer
+

Rate for periodic task execution

+
@@ -14935,18 +12770,18 @@

-
+
-
+

- +

-
+
Type: string
+ keyBytes
Type: object
@@ -15003,22 +12824,18 @@

-
-
-
-
-
+
-
+

- +

-
+
Type: object
- No Additional Properties + algorithm
Type: string
+ -
+
+
+
+
+
-
+

- +

-
+
Type: string
- + auth_provider
Type: object
+ No Additional Properties -
-
-
-
-
+
-
+

- +

-
+
Type: string
-

Pre-signed JWT token or credential string

-
+ basic
Type: object
+ No Additional Properties -
-
-
-
-
-
-
-
-
+
-
+

- +

-
+
Type: object
- No Additional Properties + basic + + + + username
Type: string
+ -
+
+
+
+
+
-
+

- +

-
+
Type: string
-

PEM-encoded private key material for mTLS authentication

-
+ password
Type: string
+ @@ -15331,18 +13121,22 @@

-
+
+
+
+
+
-
+

- +

-
+
Type: string
-

PEM-encoded client certificate for mTLS authentication

-
+ jwt
Type: object
+ No Additional Properties -
-
-
-
-
+
-
+

- +

-
+
Type: string
-

PEM-encoded CA certificate bundle for peer verification

-
+ audience
Type: string
+ diff --git a/gencode/docs/configuration_pubber.html b/gencode/docs/configuration_pubber.html index 6326999602..900b81d6ae 100644 --- a/gencode/docs/configuration_pubber.html +++ b/gencode/docs/configuration_pubber.html @@ -1244,273 +1244,6 @@

-

-
-
-
-
-
-
-

- -

-
- -
-
- - Type: string
-

Pre-signed JWT token or credential string

-
- - - - - - -
-
-
-
-
-
-
-
-
-
-
-

- -

-
- -
-
- - Type: object
- No Additional Properties - - - - - - -
-
-
-

- -

-
- -
-
- - Type: string
-

PEM-encoded private key material for mTLS authentication

-
- - - - - - -
-
-
-
-
-
-
-

- -

-
- -
-
- - Type: string
-

PEM-encoded client certificate for mTLS authentication

-
- - - - - - -
-
-
-
-
-
-
-

- -

-
- -
-
- - Type: string
-

PEM-encoded CA certificate bundle for peer verification

-
- - - - - -
diff --git a/gencode/docs/download_blobset.html b/gencode/docs/download_blobset.html index bea1660678..74f119c0a3 100644 --- a/gencode/docs/download_blobset.html +++ b/gencode/docs/download_blobset.html @@ -1554,308 +1554,6 @@

-

-
-
-
-
-
-
-

- -

-
- -
-
- - Type: string
-

Pre-signed JWT token or credential string

-
- - - - - - -
-
-
-
-
-
-
-
-
-
-
-

- -

-
- -
-
- - Type: object
- No Additional Properties - - - - - - -
-
-
-

- -

-
- -
-
- - Type: string
-

PEM-encoded private key material for mTLS authentication

-
- - - - - - -
-
-
-
-
-
-
-

- -

-
- -
-
- - Type: string
-

PEM-encoded client certificate for mTLS authentication

-
- - - - - - -
-
-
-
-
-
-
-

- -

-
- -
-
- - Type: string
-

PEM-encoded CA certificate bundle for peer verification

-
- - - - - -
diff --git a/gencode/docs/persistent_device.html b/gencode/docs/persistent_device.html index 57f569ca52..863d64e565 100644 --- a/gencode/docs/persistent_device.html +++ b/gencode/docs/persistent_device.html @@ -1244,273 +1244,6 @@

-

-
-
-
-
-
-
-

- -

-
- -
-
- - Type: string
-

Pre-signed JWT token or credential string

-
- - - - - - -
-
-
-
-
-
-
-
-
-
-
-

- -

-
- -
-
- - Type: object
- No Additional Properties - - - - - - -
-
-
-

- -

-
- -
-
- - Type: string
-

PEM-encoded private key material for mTLS authentication

-
- - - - - - -
-
-
-
-
-
-
-

- -

-
- -
-
- - Type: string
-

PEM-encoded client certificate for mTLS authentication

-
- - - - - - -
-
-
-
-
-
-
-

- -

-
- -
-
- - Type: string
-

PEM-encoded CA certificate bundle for peer verification

-
- - - - - -
diff --git a/gencode/docs/upload_discovery.html b/gencode/docs/upload_discovery.html index 958901c66d..31a77df1c2 100644 --- a/gencode/docs/upload_discovery.html +++ b/gencode/docs/upload_discovery.html @@ -106,7 +106,7 @@

+ aria-expanded="" aria-controls="family" onclick="setAnchor('#family')">family Required

@@ -140,7 +140,7 @@

+ aria-expanded="" aria-controls="generation" onclick="setAnchor('#generation')">generation Required

@@ -169,40 +169,6 @@


"2019-01-17T14:02:29.364Z"
 
-

-
-
-
-
-
-
-

- -

-
- -
-
- - Type: string
-

Unique session identifier for the chunked upload transmission

-
- - - - - -
@@ -237,74 +203,6 @@

-

-
-
-
-
-
-
-

- -

-
- -
-
- - Type: integer
-

0-based index of the transmitted chunk payload within the total session

-
- - - - - - -
-
-
-
-
-
-
-

- -

-
- -
-
- - Type: integer
-

Total number of chunks comprising the complete transmission binary

-
- - - - - -
@@ -332,7 +230,7 @@

sha256

Type: string

SHA-256 hex digest of the complete unencoded binary payload (included on the terminal negative event_no marker)

-
Must match regular expression: ^[0-9a-fA-F]{64}$ +Must match regular expression: ^[0-9a-f]{64}$ diff --git a/gencode/java/udmi/schema/Auth_provider.java b/gencode/java/udmi/schema/Auth_provider.java index e32dbb3572..562bcebd70 100644 --- a/gencode/java/udmi/schema/Auth_provider.java +++ b/gencode/java/udmi/schema/Auth_provider.java @@ -12,14 +12,11 @@ public class Auth_provider { public Basic basic; @JsonProperty("jwt") public Jwt jwt; - @JsonProperty("mtls") - public Mtls mtls; @Override public int hashCode() { int result = 1; result = ((result* 31)+((this.basic == null)? 0 :this.basic.hashCode())); - result = ((result* 31)+((this.mtls == null)? 0 :this.mtls.hashCode())); result = ((result* 31)+((this.jwt == null)? 0 :this.jwt.hashCode())); return result; } @@ -33,7 +30,7 @@ public boolean equals(Object other) { return false; } Auth_provider rhs = ((Auth_provider) other); - return ((((this.basic == rhs.basic)||((this.basic!= null)&&this.basic.equals(rhs.basic)))&&((this.mtls == rhs.mtls)||((this.mtls!= null)&&this.mtls.equals(rhs.mtls))))&&((this.jwt == rhs.jwt)||((this.jwt!= null)&&this.jwt.equals(rhs.jwt)))); + return (((this.basic == rhs.basic)||((this.basic!= null)&&this.basic.equals(rhs.basic)))&&((this.jwt == rhs.jwt)||((this.jwt!= null)&&this.jwt.equals(rhs.jwt)))); } } diff --git a/gencode/java/udmi/schema/BlobsetConfig.java b/gencode/java/udmi/schema/BlobsetConfig.java index 1d0f8aa387..7fd731c1a5 100644 --- a/gencode/java/udmi/schema/BlobsetConfig.java +++ b/gencode/java/udmi/schema/BlobsetConfig.java @@ -61,9 +61,7 @@ public boolean equals(Object other) { */ public enum SystemBlobsets { - IOT_ENDPOINT_CONFIG("_iot_endpoint_config"), - BACNET_SC_CONFIG("_bacnet_sc_config"), - DISCOVERY_AUTH("_discovery_auth"); + IOT_ENDPOINT_CONFIG("_iot_endpoint_config"); private final java.lang.String value; private final static Map CONSTANTS = new HashMap(); diff --git a/gencode/java/udmi/schema/DiscoveryUpload.java b/gencode/java/udmi/schema/DiscoveryUpload.java index 354489f358..09e174fd7e 100644 --- a/gencode/java/udmi/schema/DiscoveryUpload.java +++ b/gencode/java/udmi/schema/DiscoveryUpload.java @@ -35,6 +35,7 @@ public class DiscoveryUpload { public String version; /** * Protocol family associated with the discovery trace upload + * (Required) * */ @JsonProperty("family") @@ -42,19 +43,12 @@ public class DiscoveryUpload { public String family; /** * The discovery scan trigger's generation timestamp + * (Required) * */ @JsonProperty("generation") @JsonPropertyDescription("The discovery scan trigger's generation timestamp") public Date generation; - /** - * Unique session identifier for the chunked upload transmission - * (Required) - * - */ - @JsonProperty("session_id") - @JsonPropertyDescription("Unique session identifier for the chunked upload transmission") - public String session_id; /** * Sequence number for this upload chunk: 0 for start marker, 1..N for data chunks, -(N + 1) for terminal EOF marker * (Required) @@ -63,20 +57,6 @@ public class DiscoveryUpload { @JsonProperty("event_no") @JsonPropertyDescription("Sequence number for this upload chunk: 0 for start marker, 1..N for data chunks, -(N + 1) for terminal EOF marker") public Integer event_no; - /** - * 0-based index of the transmitted chunk payload within the total session - * - */ - @JsonProperty("chunk_index") - @JsonPropertyDescription("0-based index of the transmitted chunk payload within the total session") - public Integer chunk_index; - /** - * Total number of chunks comprising the complete transmission binary - * - */ - @JsonProperty("total_chunks") - @JsonPropertyDescription("Total number of chunks comprising the complete transmission binary") - public Integer total_chunks; /** * SHA-256 hex digest of the complete unencoded binary payload (included on the terminal negative event_no marker) * @@ -96,14 +76,11 @@ public class DiscoveryUpload { public int hashCode() { int result = 1; result = ((result* 31)+((this.generation == null)? 0 :this.generation.hashCode())); - result = ((result* 31)+((this.chunk_index == null)? 0 :this.chunk_index.hashCode())); result = ((result* 31)+((this.sha256 == null)? 0 :this.sha256 .hashCode())); result = ((result* 31)+((this.data == null)? 0 :this.data.hashCode())); result = ((result* 31)+((this.event_no == null)? 0 :this.event_no.hashCode())); - result = ((result* 31)+((this.session_id == null)? 0 :this.session_id.hashCode())); result = ((result* 31)+((this.family == null)? 0 :this.family.hashCode())); result = ((result* 31)+((this.version == null)? 0 :this.version.hashCode())); - result = ((result* 31)+((this.total_chunks == null)? 0 :this.total_chunks.hashCode())); result = ((result* 31)+((this.timestamp == null)? 0 :this.timestamp.hashCode())); return result; } @@ -117,7 +94,7 @@ public boolean equals(Object other) { return false; } DiscoveryUpload rhs = ((DiscoveryUpload) other); - return (((((((((((this.generation == rhs.generation)||((this.generation!= null)&&this.generation.equals(rhs.generation)))&&((this.chunk_index == rhs.chunk_index)||((this.chunk_index!= null)&&this.chunk_index.equals(rhs.chunk_index))))&&((this.sha256 == rhs.sha256)||((this.sha256 != null)&&this.sha256 .equals(rhs.sha256))))&&((this.data == rhs.data)||((this.data!= null)&&this.data.equals(rhs.data))))&&((this.event_no == rhs.event_no)||((this.event_no!= null)&&this.event_no.equals(rhs.event_no))))&&((this.session_id == rhs.session_id)||((this.session_id!= null)&&this.session_id.equals(rhs.session_id))))&&((this.family == rhs.family)||((this.family!= null)&&this.family.equals(rhs.family))))&&((this.version == rhs.version)||((this.version!= null)&&this.version.equals(rhs.version))))&&((this.total_chunks == rhs.total_chunks)||((this.total_chunks!= null)&&this.total_chunks.equals(rhs.total_chunks))))&&((this.timestamp == rhs.timestamp)||((this.timestamp!= null)&&this.timestamp.equals(rhs.timestamp)))); + return ((((((((this.generation == rhs.generation)||((this.generation!= null)&&this.generation.equals(rhs.generation)))&&((this.sha256 == rhs.sha256)||((this.sha256 != null)&&this.sha256 .equals(rhs.sha256))))&&((this.data == rhs.data)||((this.data!= null)&&this.data.equals(rhs.data))))&&((this.event_no == rhs.event_no)||((this.event_no!= null)&&this.event_no.equals(rhs.event_no))))&&((this.family == rhs.family)||((this.family!= null)&&this.family.equals(rhs.family))))&&((this.version == rhs.version)||((this.version!= null)&&this.version.equals(rhs.version))))&&((this.timestamp == rhs.timestamp)||((this.timestamp!= null)&&this.timestamp.equals(rhs.timestamp)))); } } diff --git a/gencode/java/udmi/schema/Jwt.java b/gencode/java/udmi/schema/Jwt.java index f3b7e89a20..6b0d3bd3ce 100644 --- a/gencode/java/udmi/schema/Jwt.java +++ b/gencode/java/udmi/schema/Jwt.java @@ -3,7 +3,6 @@ import com.fasterxml.jackson.annotation.JsonInclude; import com.fasterxml.jackson.annotation.JsonProperty; -import com.fasterxml.jackson.annotation.JsonPropertyDescription; import com.fasterxml.jackson.annotation.JsonPropertyOrder; @JsonInclude(JsonInclude.Include.NON_NULL) @@ -11,19 +10,11 @@ public class Jwt { @JsonProperty("audience") public String audience; - /** - * Pre-signed JWT token or credential string - * - */ - @JsonProperty("token") - @JsonPropertyDescription("Pre-signed JWT token or credential string") - public String token; @Override public int hashCode() { int result = 1; result = ((result* 31)+((this.audience == null)? 0 :this.audience.hashCode())); - result = ((result* 31)+((this.token == null)? 0 :this.token.hashCode())); return result; } @@ -36,7 +27,7 @@ public boolean equals(Object other) { return false; } Jwt rhs = ((Jwt) other); - return (((this.audience == rhs.audience)||((this.audience!= null)&&this.audience.equals(rhs.audience)))&&((this.token == rhs.token)||((this.token!= null)&&this.token.equals(rhs.token)))); + return ((this.audience == rhs.audience)||((this.audience!= null)&&this.audience.equals(rhs.audience))); } } diff --git a/gencode/java/udmi/schema/Mtls.java b/gencode/java/udmi/schema/Mtls.java deleted file mode 100644 index 4fc03a1d42..0000000000 --- a/gencode/java/udmi/schema/Mtls.java +++ /dev/null @@ -1,55 +0,0 @@ - -package udmi.schema; - -import com.fasterxml.jackson.annotation.JsonInclude; -import com.fasterxml.jackson.annotation.JsonProperty; -import com.fasterxml.jackson.annotation.JsonPropertyDescription; -import com.fasterxml.jackson.annotation.JsonPropertyOrder; - -@JsonInclude(JsonInclude.Include.NON_NULL) -public class Mtls { - - /** - * PEM-encoded private key material for mTLS authentication - * - */ - @JsonProperty("private_key") - @JsonPropertyDescription("PEM-encoded private key material for mTLS authentication") - public String private_key; - /** - * PEM-encoded client certificate for mTLS authentication - * - */ - @JsonProperty("certificate") - @JsonPropertyDescription("PEM-encoded client certificate for mTLS authentication") - public String certificate; - /** - * PEM-encoded CA certificate bundle for peer verification - * - */ - @JsonProperty("ca_certificate") - @JsonPropertyDescription("PEM-encoded CA certificate bundle for peer verification") - public String ca_certificate; - - @Override - public int hashCode() { - int result = 1; - result = ((result* 31)+((this.certificate == null)? 0 :this.certificate.hashCode())); - result = ((result* 31)+((this.ca_certificate == null)? 0 :this.ca_certificate.hashCode())); - result = ((result* 31)+((this.private_key == null)? 0 :this.private_key.hashCode())); - return result; - } - - @Override - public boolean equals(Object other) { - if (other == this) { - return true; - } - if ((other instanceof Mtls) == false) { - return false; - } - Mtls rhs = ((Mtls) other); - return ((((this.certificate == rhs.certificate)||((this.certificate!= null)&&this.certificate.equals(rhs.certificate)))&&((this.ca_certificate == rhs.ca_certificate)||((this.ca_certificate!= null)&&this.ca_certificate.equals(rhs.ca_certificate))))&&((this.private_key == rhs.private_key)||((this.private_key!= null)&&this.private_key.equals(rhs.private_key)))); - } - -} diff --git a/gencode/presentation/presentation.json b/gencode/presentation/presentation.json index 12344befe7..830011e76d 100644 --- a/gencode/presentation/presentation.json +++ b/gencode/presentation/presentation.json @@ -440,27 +440,6 @@ "section": "cloud_iot_config", "type": "string" }, - "reflector_endpoint.auth_provider.mtls.private_key": { - "display": "show", - "style": "bold", - "section": "cloud_iot_config", - "type": "string", - "description": "PEM-encoded private key material for mTLS authentication" - }, - "reflector_endpoint.auth_provider.mtls.certificate": { - "display": "show", - "style": "bold", - "section": "cloud_iot_config", - "type": "string", - "description": "PEM-encoded client certificate for mTLS authentication" - }, - "reflector_endpoint.auth_provider.mtls.ca_certificate": { - "display": "show", - "style": "bold", - "section": "cloud_iot_config", - "type": "string", - "description": "PEM-encoded CA certificate bundle for peer verification" - }, "reflector_endpoint.ca_file": { "display": "show", "style": "bold", @@ -644,27 +623,6 @@ "section": "cloud_iot_config", "type": "string" }, - "device_endpoint.auth_provider.mtls.private_key": { - "display": "show", - "style": "bold", - "section": "cloud_iot_config", - "type": "string", - "description": "PEM-encoded private key material for mTLS authentication" - }, - "device_endpoint.auth_provider.mtls.certificate": { - "display": "show", - "style": "bold", - "section": "cloud_iot_config", - "type": "string", - "description": "PEM-encoded client certificate for mTLS authentication" - }, - "device_endpoint.auth_provider.mtls.ca_certificate": { - "display": "show", - "style": "bold", - "section": "cloud_iot_config", - "type": "string", - "description": "PEM-encoded CA certificate bundle for peer verification" - }, "device_endpoint.ca_file": { "display": "show", "style": "bold", diff --git a/gencode/python/udmi/schema/common.py b/gencode/python/udmi/schema/common.py index 4edac9f6b7..4a1a7693b7 100644 --- a/gencode/python/udmi/schema/common.py +++ b/gencode/python/udmi/schema/common.py @@ -93,8 +93,6 @@ class Blobsets(Enum): """ field_iot_endpoint_config = '_iot_endpoint_config' - field_bacnet_sc_config = '_bacnet_sc_config' - field_discovery_auth = '_discovery_auth' @dataclass diff --git a/gencode/python/udmi/schema/configuration_endpoint.py b/gencode/python/udmi/schema/configuration_endpoint.py index 1f1d859085..0750d3c801 100644 --- a/gencode/python/udmi/schema/configuration_endpoint.py +++ b/gencode/python/udmi/schema/configuration_endpoint.py @@ -32,21 +32,12 @@ class Basic(DataModel): @dataclass class Jwt(DataModel): audience: Optional[str] = None - token: Optional[str] = None - - -@dataclass -class Mtls(DataModel): - private_key: Optional[str] = None - certificate: Optional[str] = None - ca_certificate: Optional[str] = None @dataclass class AuthProvider(DataModel): basic: Optional[Basic] = None jwt: Optional[Jwt] = None - mtls: Optional[Mtls] = None @dataclass diff --git a/gencode/python/udmi/schema/upload_discovery.py b/gencode/python/udmi/schema/upload_discovery.py index d2448020a8..f7c5cebf5d 100644 --- a/gencode/python/udmi/schema/upload_discovery.py +++ b/gencode/python/udmi/schema/upload_discovery.py @@ -19,9 +19,6 @@ class DiscoveryUpload(DataModel): version: Optional[str] = None family: Optional[str] = None generation: Optional[str] = None - session_id: Optional[str] = None event_no: Optional[int] = None - chunk_index: Optional[int] = None - total_chunks: Optional[int] = None sha256: Optional[str] = None data: Optional[str] = None diff --git a/misc/bambi/presentation.gld.json b/misc/bambi/presentation.gld.json index 12344befe7..830011e76d 100644 --- a/misc/bambi/presentation.gld.json +++ b/misc/bambi/presentation.gld.json @@ -440,27 +440,6 @@ "section": "cloud_iot_config", "type": "string" }, - "reflector_endpoint.auth_provider.mtls.private_key": { - "display": "show", - "style": "bold", - "section": "cloud_iot_config", - "type": "string", - "description": "PEM-encoded private key material for mTLS authentication" - }, - "reflector_endpoint.auth_provider.mtls.certificate": { - "display": "show", - "style": "bold", - "section": "cloud_iot_config", - "type": "string", - "description": "PEM-encoded client certificate for mTLS authentication" - }, - "reflector_endpoint.auth_provider.mtls.ca_certificate": { - "display": "show", - "style": "bold", - "section": "cloud_iot_config", - "type": "string", - "description": "PEM-encoded CA certificate bundle for peer verification" - }, "reflector_endpoint.ca_file": { "display": "show", "style": "bold", @@ -644,27 +623,6 @@ "section": "cloud_iot_config", "type": "string" }, - "device_endpoint.auth_provider.mtls.private_key": { - "display": "show", - "style": "bold", - "section": "cloud_iot_config", - "type": "string", - "description": "PEM-encoded private key material for mTLS authentication" - }, - "device_endpoint.auth_provider.mtls.certificate": { - "display": "show", - "style": "bold", - "section": "cloud_iot_config", - "type": "string", - "description": "PEM-encoded client certificate for mTLS authentication" - }, - "device_endpoint.auth_provider.mtls.ca_certificate": { - "display": "show", - "style": "bold", - "section": "cloud_iot_config", - "type": "string", - "description": "PEM-encoded CA certificate bundle for peer verification" - }, "device_endpoint.ca_file": { "display": "show", "style": "bold", diff --git a/pubber/src/main/java/udmi/lib/client/host/PublisherHost.java b/pubber/src/main/java/udmi/lib/client/host/PublisherHost.java index 58ca4d98d4..d5e0d13996 100644 --- a/pubber/src/main/java/udmi/lib/client/host/PublisherHost.java +++ b/pubber/src/main/java/udmi/lib/client/host/PublisherHost.java @@ -10,6 +10,7 @@ import static com.google.udmi.util.GeneralUtils.ifNotNullGet; import static com.google.udmi.util.GeneralUtils.ifNotNullThen; import static com.google.udmi.util.GeneralUtils.ifTrueThen; +import static com.google.udmi.util.GeneralUtils.isNotEmpty; import static com.google.udmi.util.GeneralUtils.setClockSkew; import static com.google.udmi.util.GeneralUtils.stackTraceString; import static com.google.udmi.util.GeneralUtils.toJsonFile; @@ -18,6 +19,7 @@ import static com.google.udmi.util.JsonUtil.parseJson; import static com.google.udmi.util.JsonUtil.safeSleep; import static com.google.udmi.util.JsonUtil.stringify; +import static com.google.udmi.util.JsonUtil.stringifyTerse; import static java.lang.String.format; import static java.util.Optional.ofNullable; import static udmi.lib.base.ManagerBase.INITIAL_THRESHOLD_SEC; @@ -604,7 +606,20 @@ default void downloadHandler(BlobsetDownload download) { if (endpoint != null) { BlobBlobsetConfig blobConfig = catchToNull(() -> getDeviceConfig().blobset.blobs.get(IOT_ENDPOINT_CONFIG.value())); - if (blobConfig != null && blobConfig.generation != null) { + if (blobConfig == null || blobConfig.url == null + || !blobConfig.url.startsWith(DOWNLOAD_URL_PREFIX)) { + warn("Ignoring unsolicited endpoint download"); + return; + } + String actualSha256 = GeneralUtils.sha256(stringifyTerse(endpoint)); + if (!isNotEmpty(blobConfig.sha256)) { + endpoint.error = "Missing expected blob sha256"; + } else if (!blobConfig.sha256.equals(actualSha256)) { + endpoint.error = "Blob data hash mismatch"; + } else if (endpoint.expiry != null && !endpoint.expiry.after(getNow())) { + endpoint.error = "Endpoint configuration expired"; + } + if (blobConfig.generation != null) { endpoint.generation = blobConfig.generation; } setExtractedEndpoint(endpoint); @@ -669,11 +684,15 @@ default void publisherHandler(String type, String phase, Throwable cause, String String usePhase = isBadCategory() ? "apply" : phase; String category = type == null ? UDMI_PUBLISHER_LOG_CATEGORY - : format(SYSTEM_CATEGORY_FORMAT, type, usePhase); + : "download".equals(type) + ? format("blobset.blob.%s", usePhase) + : format(SYSTEM_CATEGORY_FORMAT, type, usePhase); Entry report = entryFromException(category, cause); getDeviceManager().localLog(report); publishLogMessage(report, targetId); - registerSystemStatus(report, targetId); + if (!"download".equals(type)) { + registerSystemStatus(report, targetId); + } } /** @@ -917,6 +936,14 @@ default void maybeTweakState() { * @return The extracted {@link EndpointConfiguration} */ default EndpointConfiguration extractEndpointBlobConfig() { + BlobBlobsetConfig blobConfig = + catchToNull(() -> getDeviceConfig().blobset.blobs.get(IOT_ENDPOINT_CONFIG.value())); + if (blobConfig != null && blobConfig.url != null + && blobConfig.url.startsWith(DOWNLOAD_URL_PREFIX) + && getExtractedEndpoint() != null + && Objects.equals(getExtractedEndpoint().generation, blobConfig.generation)) { + return getExtractedEndpoint(); + } setExtractedEndpoint(null); if (getDeviceConfig().blobset == null) { return null; @@ -998,6 +1025,14 @@ default void maybeRedirectEndpoint(EndpointConfiguration extractedEndpoint) { if (extractedSignature.equals(currentSignature) || extractedSignature.equals(getAttemptedEndpoint())) { + if (extractedEndpoint != null && extractedEndpoint.error == null + && endpointState.phase == BlobBlobsetConfig.BlobPhase.APPLY) { + endpointState.phase = BlobBlobsetConfig.BlobPhase.FINAL; + endpointState.status = null; + endpointState.generation = extractedEndpoint.generation; + persistEndpoint(extractedEndpoint); + markStateDirty(); + } return; // No need to redirect anything! } diff --git a/pubber/src/main/java/udmi/lib/client/manager/DiscoveryManager.java b/pubber/src/main/java/udmi/lib/client/manager/DiscoveryManager.java index 3f2ad302ee..632643916e 100644 --- a/pubber/src/main/java/udmi/lib/client/manager/DiscoveryManager.java +++ b/pubber/src/main/java/udmi/lib/client/manager/DiscoveryManager.java @@ -5,6 +5,7 @@ import static com.google.udmi.util.GeneralUtils.ifNullThen; import static com.google.udmi.util.GeneralUtils.ifTrueGet; import static com.google.udmi.util.GeneralUtils.ifTrueThen; +import static com.google.udmi.util.GeneralUtils.sha256; import static com.google.udmi.util.JsonUtil.getNowInstant; import static com.google.udmi.util.JsonUtil.isoConvert; import static java.lang.Math.floorMod; @@ -15,8 +16,10 @@ import static udmi.schema.FamilyDiscoveryState.Phase.STOPPED; import com.google.common.collect.ImmutableSet; +import java.nio.charset.StandardCharsets; import java.time.Instant; import java.util.ArrayList; +import java.util.Base64; import java.util.Date; import java.util.HashMap; import java.util.List; @@ -32,6 +35,7 @@ import udmi.schema.DiscoveryConfig; import udmi.schema.DiscoveryEvents; import udmi.schema.DiscoveryState; +import udmi.schema.DiscoveryUpload; import udmi.schema.Enumerations; import udmi.schema.Enumerations.Depth; import udmi.schema.FamilyDiscoveryConfig; @@ -278,9 +282,17 @@ default void startDiscoveryScan(String family, Date scanGeneration) { familyDiscoveryState.generation = scanGeneration; familyDiscoveryState.phase = ACTIVE; familyDiscoveryState.active_count = sendCount.get(); - sendMarkerDiscoveryEvent(family, scanGeneration, sendCount); + if (!isTraceDepth(family)) { + sendMarkerDiscoveryEvent(family, scanGeneration, sendCount); + } updateState(); - startDiscoveryForFamily(family, scanGeneration, familyDiscoveryState, sendCount); + if (!isTraceDepth(family)) { + startDiscoveryForFamily(family, scanGeneration, familyDiscoveryState, sendCount); + } + } + + default boolean isTraceDepth(String family) { + return Depth.TRACE.equals(catchToNull(() -> getFamilyDiscoveryConfig(family).depth)); } private void sendMarkerDiscoveryEvent(String family, Date scanGeneration, @@ -292,6 +304,43 @@ private void sendMarkerDiscoveryEvent(String family, Date scanGeneration, publishDiscoveryEvent(family, scanGeneration, null, discoveryEvent); } + /** + * Publishes a discovery upload stream for trace-depth scans. + */ + default void publishTraceDiscoveryUploads(String family, Date scanGeneration, + FamilyDiscoveryState familyDiscoveryState) { + final List chunks = List.of( + "PCAP-HEADER-AND-FIRST-PACKET-".getBytes(StandardCharsets.UTF_8), + "SECOND-PACKET-AND-FOOTER".getBytes(StandardCharsets.UTF_8)); + final byte[] fullPayload = "PCAP-HEADER-AND-FIRST-PACKET-SECOND-PACKET-AND-FOOTER" + .getBytes(StandardCharsets.UTF_8); + final int totalChunks = chunks.size(); + + DiscoveryUpload startUpload = new DiscoveryUpload(); + startUpload.family = family; + startUpload.generation = scanGeneration; + startUpload.event_no = 0; + getHost().publish(startUpload); + + for (int idx = 0; idx < totalChunks; idx++) { + DiscoveryUpload chunkUpload = new DiscoveryUpload(); + chunkUpload.family = family; + chunkUpload.generation = scanGeneration; + chunkUpload.event_no = idx + 1; + chunkUpload.data = Base64.getEncoder().encodeToString(chunks.get(idx)); + getHost().publish(chunkUpload); + } + + DiscoveryUpload eofUpload = new DiscoveryUpload(); + eofUpload.family = family; + eofUpload.generation = scanGeneration; + eofUpload.event_no = -(totalChunks + 1); + eofUpload.sha256 = sha256(fullPayload); + getHost().publish(eofUpload); + + familyDiscoveryState.active_count = totalChunks; + } + /** * Starts a discovery scan for given family. */ @@ -379,11 +428,15 @@ default void discoveryScanComplete(String family, Date scanGeneration, AtomicInt info(format("Discovered %s stopping %s (=? %s)", family, isoConvert(scanGeneration), isoConvert(familyDiscoveryState.generation))); ifTrueThen(scanGeneration.equals(familyDiscoveryState.generation), () -> { - FamilyProvider provider = discoveryProvider(family); - if (provider != null) { - provider.stopScan(); + if (isTraceDepth(family)) { + publishTraceDiscoveryUploads(family, scanGeneration, familyDiscoveryState); + } else { + FamilyProvider provider = discoveryProvider(family); + if (provider != null) { + provider.stopScan(); + } + sendMarkerDiscoveryEvent(family, scanGeneration, sendCount); } - sendMarkerDiscoveryEvent(family, scanGeneration, sendCount); familyDiscoveryState.phase = STOPPED; updateState(); scheduleDiscoveryScan(family); diff --git a/pubber/src/test/java/daq/pubber/PubberTest.java b/pubber/src/test/java/daq/pubber/PubberTest.java index 42c505556b..20691d3179 100644 --- a/pubber/src/test/java/daq/pubber/PubberTest.java +++ b/pubber/src/test/java/daq/pubber/PubberTest.java @@ -290,4 +290,69 @@ public void initializePersistentStoreFromPersistentDataTest() { assertEquals(pubber.persistentData.endpoint.hostname, "persistent"); assertEquals(pubber.getPubberConfig().endpoint.hostname, "persistent"); } + + @Test + public void downloadEndpointFlowTest() { + pubber = makeTestPubber(TEST_DEVICE); + final EndpointConfiguration targetEndpoint = getEndpointConfiguration(TEST_REDIRECT_HOSTNAME); + final Date generation = new Date(); + BlobBlobsetConfig blobConfig = new BlobBlobsetConfig(); + blobConfig.url = "download://_iot_endpoint_config"; + blobConfig.sha256 = sha256(JsonUtil.stringifyTerse(targetEndpoint)); + blobConfig.phase = BlobPhase.FINAL; + blobConfig.generation = generation; + pubber.getDeviceConfig().blobset = new BlobsetConfig(); + pubber.getDeviceConfig().blobset.blobs = new HashMap<>(); + pubber.getDeviceConfig().blobset.blobs.put(IOT_ENDPOINT_CONFIG.value(), blobConfig); + + assertNull(pubber.extractEndpointBlobConfig()); + assertEquals(BlobPhase.APPLY, + pubber.getDeviceState().blobset.blobs.get(IOT_ENDPOINT_CONFIG.value()).phase); + + udmi.schema.BlobsetDownload download = new udmi.schema.BlobsetDownload(); + download.blobs = new HashMap<>(); + download.blobs.put(IOT_ENDPOINT_CONFIG.value(), targetEndpoint); + pubber.downloadHandler(download); + + assertNotEquals(null, pubber.getExtractedEndpoint()); + assertNull(pubber.getExtractedEndpoint().error); + assertEquals(generation, pubber.getExtractedEndpoint().generation); + + // Subsequent config extraction with same generation must preserve downloaded endpoint + assertNotEquals(null, pubber.extractEndpointBlobConfig()); + + pubber.maybeRedirectEndpoint(pubber.getExtractedEndpoint()); + assertEquals(BlobPhase.FINAL, + pubber.getDeviceState().blobset.blobs.get(IOT_ENDPOINT_CONFIG.value()).phase); + assertNull(pubber.getDeviceState().blobset.blobs.get(IOT_ENDPOINT_CONFIG.value()).status); + } + + @Test + public void downloadEndpointBadHashTest() { + pubber = makeTestPubber(TEST_DEVICE); + final EndpointConfiguration targetEndpoint = getEndpointConfiguration(TEST_REDIRECT_HOSTNAME); + final Date generation = new Date(); + BlobBlobsetConfig blobConfig = new BlobBlobsetConfig(); + blobConfig.url = "download://_iot_endpoint_config"; + blobConfig.sha256 = "0000000000000000000000000000000000000000000000000000000000000000"; + blobConfig.phase = BlobPhase.FINAL; + blobConfig.generation = generation; + pubber.getDeviceConfig().blobset = new BlobsetConfig(); + pubber.getDeviceConfig().blobset.blobs = new HashMap<>(); + pubber.getDeviceConfig().blobset.blobs.put(IOT_ENDPOINT_CONFIG.value(), blobConfig); + + assertNull(pubber.extractEndpointBlobConfig()); + + udmi.schema.BlobsetDownload download = new udmi.schema.BlobsetDownload(); + download.blobs = new HashMap<>(); + download.blobs.put(IOT_ENDPOINT_CONFIG.value(), targetEndpoint); + pubber.downloadHandler(download); + + assertEquals("Blob data hash mismatch", pubber.getExtractedEndpoint().error); + pubber.maybeRedirectEndpoint(pubber.getExtractedEndpoint()); + assertEquals(BlobPhase.FINAL, + pubber.getDeviceState().blobset.blobs.get(IOT_ENDPOINT_CONFIG.value()).phase); + assertEquals(udmi.schema.Category.BLOBSET_BLOB_APPLY, + pubber.getDeviceState().blobset.blobs.get(IOT_ENDPOINT_CONFIG.value()).status.category); + } } diff --git a/schema/common.json b/schema/common.json index a38eb871d4..87594ca7e9 100644 --- a/schema/common.json +++ b/schema/common.json @@ -97,9 +97,7 @@ "title": "System Blobsets", "description": "Predefined system blobsets", "enum": [ - "_iot_endpoint_config", - "_bacnet_sc_config", - "_discovery_auth" + "_iot_endpoint_config" ] } } diff --git a/schema/configuration_endpoint.json b/schema/configuration_endpoint.json index 62763bb627..846236c790 100644 --- a/schema/configuration_endpoint.json +++ b/schema/configuration_endpoint.json @@ -219,44 +219,6 @@ "display": "show", "style": "bold" } - }, - "token": { - "description": "Pre-signed JWT token or credential string", - "type": "string", - "$presentation": { - "display": "show", - "style": "bold" - } - } - } - }, - "mtls": { - "type": "object", - "additionalProperties": false, - "properties": { - "private_key": { - "description": "PEM-encoded private key material for mTLS authentication", - "type": "string", - "$presentation": { - "display": "show", - "style": "bold" - } - }, - "certificate": { - "description": "PEM-encoded client certificate for mTLS authentication", - "type": "string", - "$presentation": { - "display": "show", - "style": "bold" - } - }, - "ca_certificate": { - "description": "PEM-encoded CA certificate bundle for peer verification", - "type": "string", - "$presentation": { - "display": "show", - "style": "bold" - } } } } diff --git a/schema/upload_discovery.json b/schema/upload_discovery.json index 5511fe99d6..82e8a64e65 100644 --- a/schema/upload_discovery.json +++ b/schema/upload_discovery.json @@ -27,26 +27,14 @@ "format": "date-time", "examples": ["2019-01-17T14:02:29.364Z"] }, - "session_id": { - "description": "Unique session identifier for the chunked upload transmission", - "type": "string" - }, "event_no": { "description": "Sequence number for this upload chunk: 0 for start marker, 1..N for data chunks, -(N + 1) for terminal EOF marker", "type": "integer" }, - "chunk_index": { - "description": "0-based index of the transmitted chunk payload within the total session", - "type": "integer" - }, - "total_chunks": { - "description": "Total number of chunks comprising the complete transmission binary", - "type": "integer" - }, "sha256": { "description": "SHA-256 hex digest of the complete unencoded binary payload (included on the terminal negative event_no marker)", "type": "string", - "pattern": "^[0-9a-fA-F]{64}$" + "pattern": "^[0-9a-f]{64}$" }, "data": { "description": "Base64-encoded binary chunk payload data", @@ -56,7 +44,8 @@ "required": [ "timestamp", "version", - "session_id", + "family", + "generation", "event_no" ] } diff --git a/tests/schemas/download_blobset/endpoint.json b/tests/schemas/download_blobset/endpoint.json index bf5d7e5627..ef2066681f 100644 --- a/tests/schemas/download_blobset/endpoint.json +++ b/tests/schemas/download_blobset/endpoint.json @@ -15,14 +15,12 @@ "generation": "2019-01-17T14:02:00.000Z", "expiry": "2019-01-18T14:02:00.000Z", "auth_provider": { - "jwt": { - "audience": "example-project", - "token": "eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9.payload.signature" + "basic": { + "username": "AHU-1", + "password": "rotated-secret-password" }, - "mtls": { - "private_key": "-----BEGIN PRIVATE KEY-----\nMIIEvQIBADANBgkqhkiG9w0BAQEFAASCBKcwggSjAgEAAoIBAQC\n-----END PRIVATE KEY-----", - "certificate": "-----BEGIN CERTIFICATE-----\nMIIDXTCCAkWgAwIBAgIJALa\n-----END CERTIFICATE-----", - "ca_certificate": "-----BEGIN CERTIFICATE-----\nMIIDxTCCAq2gAwIBAgIQAqxcJmoLQJuPC3nyrkYldzANBgkqhkiG9w0BAQUFADBs\n-----END CERTIFICATE-----" + "jwt": { + "audience": "example-project" } } } diff --git a/tests/schemas/upload_discovery/chunk.json b/tests/schemas/upload_discovery/chunk.json index 59398f8efc..59b05ad4d6 100644 --- a/tests/schemas/upload_discovery/chunk.json +++ b/tests/schemas/upload_discovery/chunk.json @@ -7,9 +7,6 @@ "timestamp": "2019-01-17T14:02:29.364Z", "family": "bacnet", "generation": "2019-01-17T14:02:00.000Z", - "session_id": "trace-bacnet-2019-01-17T14-02-00Z", "event_no": 1, - "chunk_index": 0, - "total_chunks": 2, "data": "dWRtaS1wY2FwLWNodW5rLWRhdGE=" } diff --git a/tests/schemas/upload_discovery/eof.json b/tests/schemas/upload_discovery/eof.json index d0fbe97490..bfadb34ecd 100644 --- a/tests/schemas/upload_discovery/eof.json +++ b/tests/schemas/upload_discovery/eof.json @@ -6,8 +6,6 @@ "timestamp": "2019-01-17T14:02:31.364Z", "family": "bacnet", "generation": "2019-01-17T14:02:00.000Z", - "session_id": "trace-bacnet-2019-01-17T14-02-00Z", "event_no": -3, - "total_chunks": 2, "sha256": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855" } diff --git a/udmis/etc/local_pod.json b/udmis/etc/local_pod.json index 99233d0548..9f2e827267 100644 --- a/udmis/etc/local_pod.json +++ b/udmis/etc/local_pod.json @@ -49,6 +49,10 @@ "uufi_events": { "recv_id": "events/#", "send_id": "/uufi/#" + }, + "uufi_upload": { + "recv_id": "upload/#", + "send_id": "/uufi/#" } }, "crons": { diff --git a/udmis/src/main/java/com/google/bos/udmi/service/access/ZanzaraIotAccessProvider.java b/udmis/src/main/java/com/google/bos/udmi/service/access/ZanzaraIotAccessProvider.java index 59c5d4b38d..ba8e3eb7c2 100644 --- a/udmis/src/main/java/com/google/bos/udmi/service/access/ZanzaraIotAccessProvider.java +++ b/udmis/src/main/java/com/google/bos/udmi/service/access/ZanzaraIotAccessProvider.java @@ -930,6 +930,7 @@ public void sendDownloadBase(Envelope baseEnvelope, SubFolder folder, String mes } try { Envelope envelope = deepCopy(baseEnvelope); + envelope.gatewayId = null; envelope.subFolder = folder; envelope.subType = SubType.DOWNLOAD; envelope.source = IotProvider.ZANZARA.value(); diff --git a/udmis/src/main/java/com/google/bos/udmi/service/core/ProcessorBase.java b/udmis/src/main/java/com/google/bos/udmi/service/core/ProcessorBase.java index 319967b984..00f2baf872 100644 --- a/udmis/src/main/java/com/google/bos/udmi/service/core/ProcessorBase.java +++ b/udmis/src/main/java/com/google/bos/udmi/service/core/ProcessorBase.java @@ -171,6 +171,28 @@ protected String processConfigChange(Envelope envelope, Object payload, Date new return configUpdate; } + protected void processDownloadChange(Envelope envelope, Object payload) { + if (envelope.subFolder != SubFolder.BLOBSET || payload == null || iotAccess == null) { + return; + } + udmi.schema.BlobsetDownload download = + com.google.udmi.util.JsonUtil.convertTo(udmi.schema.BlobsetDownload.class, payload); + if (download == null || download.blobs == null) { + return; + } + String registryId = envelope.deviceRegistryId; + String deviceId = envelope.deviceId; + download.blobs.forEach( + (key, endpoint) -> iotAccess.provisionDownloadBlob(registryId, deviceId, key, endpoint)); + String currentState = iotAccess.fetchState(registryId, deviceId); + if (isNotEmpty(currentState)) { + StateUpdate stateUpdate = fromJsonString(currentState, StateUpdate.class); + if (stateUpdate != null && stateUpdate.blobset != null) { + iotAccess.handleBlobsetState(envelope, stateUpdate.blobset); + } + } + } + protected void reflectError(SubType subType, BundleException bundleException) { Bundle bundle = bundleException.bundle; Map errorMap = bundle.attributesMap; diff --git a/udmis/src/main/java/com/google/bos/udmi/service/core/ReflectProcessor.java b/udmis/src/main/java/com/google/bos/udmi/service/core/ReflectProcessor.java index 84c3e0f9e5..a9040da863 100644 --- a/udmis/src/main/java/com/google/bos/udmi/service/core/ReflectProcessor.java +++ b/udmis/src/main/java/com/google/bos/udmi/service/core/ReflectProcessor.java @@ -363,6 +363,9 @@ private CloudModel reflectProcess(Envelope attributes, Object payload) { // If there was actually a config change, then the transaction will be acknowledged that way. ifNotNullThen(processConfigChange(attributes, payload, null), updated -> attributes.transactionId = makeConfigUpdateTransaction(attributes.transactionId)); + } else if (attributes.subType == SubType.DOWNLOAD) { + processDownloadChange(attributes, payload); + return reply; } if (payload instanceof String) { return reply; diff --git a/udmis/src/main/java/com/google/bos/udmi/service/core/TargetProcessor.java b/udmis/src/main/java/com/google/bos/udmi/service/core/TargetProcessor.java index fb000d7c4b..76cbc3a1f8 100644 --- a/udmis/src/main/java/com/google/bos/udmi/service/core/TargetProcessor.java +++ b/udmis/src/main/java/com/google/bos/udmi/service/core/TargetProcessor.java @@ -77,17 +77,24 @@ public void discoveryUploadHandler(udmi.schema.DiscoveryUpload message) { Envelope envelope = continuation.getEnvelope(); defaultFields(message); publish(message); + if (envelope.deviceId == null) { + notice("Dropping message with no deviceId: " + stringifyTerse(envelope)); + return; + } + if (envelope.subType == null) { + envelope.subType = SubType.UPLOAD; + } + if (envelope.subFolder == null) { + envelope.subFolder = SubFolder.DISCOVERY; + } updateLastSeen(envelope); - com.google.bos.udmi.service.support.IotDataProvider dataProvider = - com.google.bos.udmi.service.pod.UdmiServicePod.maybeGetComponent( - UploadProcessor.DATABASE_COMPONENT); - if (dataProvider != null && envelope.deviceId != null && envelope.deviceRegistryId != null) { - UploadProcessor uploadProcessor = - com.google.bos.udmi.service.pod.UdmiServicePod.maybeGetComponent(UploadProcessor.class); - java.io.File outDir = uploadProcessor != null - ? uploadProcessor.getOutputDir() - : new java.io.File("/tmp/udmi_uploads"); - UploadProcessor.processDiscoveryUpload(envelope, message, dataProvider, outDir); + UploadProcessor uploadProcessor = + com.google.bos.udmi.service.pod.UdmiServicePod.maybeGetComponent(UploadProcessor.class); + if (uploadProcessor != null) { + uploadProcessor.handleDiscoveryUpload(envelope, message); + } else { + reflectMessage(envelope, stringify(message)); + UploadProcessor.processDiscoveryUpload(envelope, message); } } diff --git a/udmis/src/main/java/com/google/bos/udmi/service/core/UploadProcessor.java b/udmis/src/main/java/com/google/bos/udmi/service/core/UploadProcessor.java index fdde1aa4c5..5be4c60740 100644 --- a/udmis/src/main/java/com/google/bos/udmi/service/core/UploadProcessor.java +++ b/udmis/src/main/java/com/google/bos/udmi/service/core/UploadProcessor.java @@ -1,6 +1,7 @@ package com.google.bos.udmi.service.core; -import static com.google.udmi.util.GeneralUtils.ifNotNullThen; +import static com.google.udmi.util.JsonUtil.isoConvert; +import static com.google.udmi.util.JsonUtil.stringify; import static java.lang.String.format; import static java.util.Objects.requireNonNull; @@ -20,20 +21,23 @@ import udmi.schema.DiscoveryUpload; import udmi.schema.EndpointConfiguration; import udmi.schema.Envelope; +import udmi.schema.Envelope.SubFolder; import udmi.schema.Envelope.SubType; /** * Processor for device-to-cloud chunked binary uploads over the {@code upload} channel. * - *

Stages incoming chunks in the shared {@link IotDataProvider} (Etcd) so multiple UDMIS pods - * can receive chunks in any order and uses an atomic Compare-And-Swap election to ensure exactly - * one pod reassembles and verifies the complete payload once all chunks and the negative EOF - * boundary marker have arrived. + *

Stages incoming chunks in the shared {@link IotDataProvider} (Etcd) keyed by + * {@code (family, generation)} so multiple UDMIS pods can receive chunks in any order and uses an + * atomic Compare-And-Swap election to ensure exactly one pod reassembles and verifies the complete + * payload once the start marker, all data chunks, and the negative EOF boundary marker have + * arrived. */ @ComponentName("upload") public class UploadProcessor extends ProcessorBase { public static final String DATABASE_COMPONENT = "database"; + public static final String STARTED_KEY = "started"; public static final String CHUNK_KEY_PREFIX = "chunk_"; public static final String EXPECTED_CHUNKS_KEY = "expected_chunks"; public static final String EXPECTED_SHA256_KEY = "expected_sha256"; @@ -73,12 +77,46 @@ public File getOutputDir() { @MessageHandler public void discoveryUploadHandler(DiscoveryUpload message) { MessageContinuation continuation = getContinuation(message); - Envelope envelope = continuation.getEnvelope(); + handleDiscoveryUpload(continuation.getEnvelope(), message); + } + + /** + * Reflect and stage a discovery upload message using this processor's configured resources. + */ + public void handleDiscoveryUpload(Envelope envelope, DiscoveryUpload message) { + if (envelope.subType == null) { + envelope.subType = SubType.UPLOAD; + } + if (envelope.subFolder == null) { + envelope.subFolder = SubFolder.DISCOVERY; + } + reflectMessage(envelope, stringify(message)); IotDataProvider dataProvider = database != null ? database : UdmiServicePod.maybeGetComponent(DATABASE_COMPONENT); - requireNonNull(dataProvider, "Missing database component for upload reassembly"); - processDiscoveryUpload(envelope, message, dataProvider, outputDir); + if (dataProvider != null && envelope.deviceId != null && envelope.deviceRegistryId != null) { + File outputFile = processDiscoveryUpload(envelope, message, dataProvider, outputDir); + if (outputFile != null) { + info("Reassembled discovery upload for %s/%s to %s", + envelope.deviceRegistryId, envelope.deviceId, outputFile.getAbsolutePath()); + } + } + } + + /** + * Stage a discovery upload chunk or boundary marker using the active pod's database and output + * directory. + */ + public static File processDiscoveryUpload(Envelope envelope, DiscoveryUpload message) { + IotDataProvider dataProvider = UdmiServicePod.maybeGetComponent(DATABASE_COMPONENT); + if (dataProvider == null || envelope.deviceId == null || envelope.deviceRegistryId == null) { + return null; + } + UploadProcessor uploadProcessor = UdmiServicePod.maybeGetComponent(UploadProcessor.class); + File outDir = uploadProcessor != null + ? uploadProcessor.getOutputDir() + : new File(DEFAULT_UPLOAD_DIR); + return processDiscoveryUpload(envelope, message, dataProvider, outDir); } /** @@ -92,46 +130,40 @@ public static File processDiscoveryUpload( File baseOutputDir) { String registryId = requireNonNull(envelope.deviceRegistryId, "missing deviceRegistryId"); String deviceId = requireNonNull(envelope.deviceId, "missing deviceId"); - String sessionId = requireNonNull(message.session_id, "missing session_id"); + String family = requireNonNull(message.family, "missing family"); + String generation = requireNonNull(isoConvert(message.generation), "missing generation"); Integer eventNo = requireNonNull(message.event_no, "missing event_no"); - String sanitizedSession = sessionId.replaceAll("[:/]", "_"); + String uploadKey = format("%s_%s", family, generation).replaceAll("[^a-zA-Z0-9._-]", "_"); DataRef uploadRef = dataProvider.ref() .registry(registryId) .device(deviceId) - .collection("upload_" + sanitizedSession); + .collection("upload_" + uploadKey); if (eventNo == 0) { Map startPuts = new HashMap<>(); - startPuts.put("started", "true"); - ifNotNullThen(message.family, family -> startPuts.put("family", family)); - if (message.total_chunks != null && message.total_chunks > 0) { - startPuts.put(EXPECTED_CHUNKS_KEY, String.valueOf(message.total_chunks)); - } + startPuts.put(STARTED_KEY, "true"); + startPuts.put("family", family); + startPuts.put("generation", generation); uploadRef.update(startPuts, null); - return null; } else if (eventNo > 0) { if (message.data == null || message.data.isEmpty()) { throw new IllegalArgumentException( - format("Upload chunk event_no %d for session %s missing data payload", - eventNo, sessionId)); + format("Upload chunk event_no %d for %s missing data payload", eventNo, uploadKey)); } Map chunkPuts = new HashMap<>(); chunkPuts.put(CHUNK_KEY_PREFIX + eventNo, message.data); - if (message.total_chunks != null && message.total_chunks > 0) { - chunkPuts.put(EXPECTED_CHUNKS_KEY, String.valueOf(message.total_chunks)); - } uploadRef.update(chunkPuts, null); } else { int expectedChunks = -(eventNo + 1); if (expectedChunks <= 0) { throw new IllegalArgumentException( - format("Invalid terminal event_no %d for session %s", eventNo, sessionId)); + format("Invalid terminal event_no %d for %s", eventNo, uploadKey)); } if (message.sha256 == null || message.sha256.isEmpty()) { throw new IllegalArgumentException( - format("Terminal upload marker event_no %d for session %s missing sha256", - eventNo, sessionId)); + format("Terminal upload marker event_no %d for %s missing sha256", + eventNo, uploadKey)); } Map eofPuts = new HashMap<>(); eofPuts.put(EXPECTED_CHUNKS_KEY, String.valueOf(expectedChunks)); @@ -140,9 +172,10 @@ public static File processDiscoveryUpload( } Map entries = uploadRef.entries(); + String started = entries.get(STARTED_KEY); String expectedChunksStr = entries.get(EXPECTED_CHUNKS_KEY); String expectedSha256 = entries.get(EXPECTED_SHA256_KEY); - if (expectedChunksStr == null || expectedSha256 == null) { + if (started == null || expectedChunksStr == null || expectedSha256 == null) { return null; } @@ -169,21 +202,21 @@ public static File processDiscoveryUpload( String actualSha256 = computeSha256Hex(payloadBytes); if (!expectedSha256.equalsIgnoreCase(actualSha256)) { throw new IllegalStateException(format( - "SHA-256 mismatch for upload session %s: expected %s, got %s", - sessionId, expectedSha256, actualSha256)); + "SHA-256 mismatch for upload %s: expected %s, got %s", + uploadKey, expectedSha256, actualSha256)); } File deviceOutputDir = new File(new File(baseOutputDir, registryId), deviceId); Files.createDirectories(deviceOutputDir.toPath()); - File outputFile = new File(deviceOutputDir, sanitizedSession + ".pcap"); + File outputFile = new File(deviceOutputDir, uploadKey + ".pcap"); Files.write(outputFile.toPath(), payloadBytes); return outputFile; } catch (RuntimeException e) { throw e; } catch (Exception e) { throw new RuntimeException( - format("While reassembling upload session %s for %s/%s", - sessionId, registryId, deviceId), e); + format("While reassembling upload %s for %s/%s", + uploadKey, registryId, deviceId), e); } finally { uploadRef.update(null, cleanupKeys); } diff --git a/udmis/src/main/java/com/google/bos/udmi/service/core/UufiProcessor.java b/udmis/src/main/java/com/google/bos/udmi/service/core/UufiProcessor.java index dbae389406..65d692ff05 100644 --- a/udmis/src/main/java/com/google/bos/udmi/service/core/UufiProcessor.java +++ b/udmis/src/main/java/com/google/bos/udmi/service/core/UufiProcessor.java @@ -172,6 +172,9 @@ private void handleUufiInbound(Envelope envelope, Map messageMap publish(innerEnvelope, com.google.udmi.util.JsonUtil.toMap(configUpdate)); } return; + } else if (innerEnvelope.subType == SubType.DOWNLOAD) { + processDownloadChange(innerEnvelope, innerPayload); + return; } publish(innerEnvelope, innerPayload); diff --git a/udmis/src/main/java/com/google/bos/udmi/service/pod/UdmiServicePod.java b/udmis/src/main/java/com/google/bos/udmi/service/pod/UdmiServicePod.java index 6053c39d1f..a12cdddf87 100644 --- a/udmis/src/main/java/com/google/bos/udmi/service/pod/UdmiServicePod.java +++ b/udmis/src/main/java/com/google/bos/udmi/service/pod/UdmiServicePod.java @@ -70,6 +70,7 @@ public class UdmiServicePod extends ContainerBase { PROCESSORS.put("uufi_out", UufiProcessor.class); PROCESSORS.put("uufi_state", UufiProcessor.class); PROCESSORS.put("uufi_events", UufiProcessor.class); + PROCESSORS.put("uufi_upload", UufiProcessor.class); } /** diff --git a/udmis/src/test/java/com/google/bos/udmi/service/access/ZanzaraIotAccessProviderTest.java b/udmis/src/test/java/com/google/bos/udmi/service/access/ZanzaraIotAccessProviderTest.java index cdb8b4a7c1..418f281561 100644 --- a/udmis/src/test/java/com/google/bos/udmi/service/access/ZanzaraIotAccessProviderTest.java +++ b/udmis/src/test/java/com/google/bos/udmi/service/access/ZanzaraIotAccessProviderTest.java @@ -378,6 +378,7 @@ void testHandleBlobsetStatePushesDownload() throws Exception { udmi.schema.Envelope env = new udmi.schema.Envelope(); env.deviceRegistryId = TEST_REGISTRY; env.deviceId = TEST_DEVICE; + env.gatewayId = "uufi"; provider.handleBlobsetState(env, blobsetState); @@ -389,6 +390,7 @@ void testHandleBlobsetStatePushesDownload() throws Exception { bundleCaptor.getValue(); assertEquals(udmi.schema.Envelope.SubType.DOWNLOAD, published.envelope.subType); assertEquals(udmi.schema.Envelope.SubFolder.BLOBSET, published.envelope.subFolder); + org.junit.jupiter.api.Assertions.assertNull(published.envelope.gatewayId); } diff --git a/udmis/src/test/java/com/google/bos/udmi/service/core/ReflectProcessorTest.java b/udmis/src/test/java/com/google/bos/udmi/service/core/ReflectProcessorTest.java index 71e2e91923..be400efaeb 100644 --- a/udmis/src/test/java/com/google/bos/udmi/service/core/ReflectProcessorTest.java +++ b/udmis/src/test/java/com/google/bos/udmi/service/core/ReflectProcessorTest.java @@ -214,4 +214,29 @@ public void updateAwarenessPayloadLogging() { System.setOut(originalOut); } } + + @Test + public void downloadBlobsetProvisionTest() { + udmi.schema.EndpointConfiguration endpoint = new udmi.schema.EndpointConfiguration(); + endpoint.hostname = "localhost"; + endpoint.client_id = "r/test-reg/d/test-dev"; + udmi.schema.BlobsetDownload download = new udmi.schema.BlobsetDownload(); + download.blobs = new java.util.HashMap<>(ImmutableMap.of("_iot_endpoint_config", endpoint)); + + Envelope reflect = makeTestEnvelope(false); + reflect.transactionId = transactionId; + reflect.subType = SubType.DOWNLOAD; + reflect.subFolder = SubFolder.BLOBSET; + reflect.payload = encodeBase64(stringify(download)); + Bundle bundle = new Bundle(makeEnvelope(SubType.EVENTS, SubFolder.UDMI), reflect); + + activeTestInstance(() -> getReverseDispatcher().publish(bundle)); + + ArgumentCaptor endpointCaptor = + ArgumentCaptor.forClass(udmi.schema.EndpointConfiguration.class); + verify(provider, times(1)).provisionDownloadBlob( + eq(TEST_REGISTRY), eq(TEST_DEVICE), eq("_iot_endpoint_config"), endpointCaptor.capture()); + assertEquals("localhost", endpointCaptor.getValue().hostname); + assertEquals("r/test-reg/d/test-dev", endpointCaptor.getValue().client_id); + } } diff --git a/udmis/src/test/java/com/google/bos/udmi/service/core/UploadProcessorTest.java b/udmis/src/test/java/com/google/bos/udmi/service/core/UploadProcessorTest.java index 20f8968d9b..a73ef9fce8 100644 --- a/udmis/src/test/java/com/google/bos/udmi/service/core/UploadProcessorTest.java +++ b/udmis/src/test/java/com/google/bos/udmi/service/core/UploadProcessorTest.java @@ -59,39 +59,37 @@ void testOutOfOrderMultiPodReassemblyAndSha256Verification() throws Exception { final byte[] fullPayload = "PCAP-HEADER-AND-FIRST-PACKET-SECOND-PACKET-AND-FOOTER" .getBytes(StandardCharsets.UTF_8); final String expectedSha256 = UploadProcessor.computeSha256Hex(fullPayload); - final String sessionId = "trace-bacnet-2026-09-27T12:00:00Z"; + final java.util.Date generation = + com.google.udmi.util.JsonUtil.getDate("2026-09-27T12:00:00Z"); // Pod 1 receives start marker (event_no = 0) DiscoveryUpload startMsg = new DiscoveryUpload(); - startMsg.session_id = sessionId; - startMsg.event_no = 0; startMsg.family = "bacnet"; - startMsg.total_chunks = 2; + startMsg.generation = generation; + startMsg.event_no = 0; assertNull(UploadProcessor.processDiscoveryUpload(envelope, startMsg, dataProvider, tempDir)); // Pod 2 receives chunk 2 out-of-order (event_no = 2) DiscoveryUpload chunk2Msg = new DiscoveryUpload(); - chunk2Msg.session_id = sessionId; + chunk2Msg.family = "bacnet"; + chunk2Msg.generation = generation; chunk2Msg.event_no = 2; - chunk2Msg.chunk_index = 1; - chunk2Msg.total_chunks = 2; chunk2Msg.data = Base64.getEncoder().encodeToString(chunk2Bytes); assertNull(UploadProcessor.processDiscoveryUpload(envelope, chunk2Msg, dataProvider, tempDir)); // Pod 3 receives terminal EOF marker (event_no = -3) before chunk 1 arrives DiscoveryUpload eofMsg = new DiscoveryUpload(); - eofMsg.session_id = sessionId; + eofMsg.family = "bacnet"; + eofMsg.generation = generation; eofMsg.event_no = -3; - eofMsg.total_chunks = 2; eofMsg.sha256 = expectedSha256; assertNull(UploadProcessor.processDiscoveryUpload(envelope, eofMsg, dataProvider, tempDir)); // Pod 1 receives chunk 1 (event_no = 1), completing the set and winning CAS election DiscoveryUpload chunk1Msg = new DiscoveryUpload(); - chunk1Msg.session_id = sessionId; + chunk1Msg.family = "bacnet"; + chunk1Msg.generation = generation; chunk1Msg.event_no = 1; - chunk1Msg.chunk_index = 0; - chunk1Msg.total_chunks = 2; chunk1Msg.data = Base64.getEncoder().encodeToString(chunk1Bytes); File reassembled = UploadProcessor.processDiscoveryUpload(envelope, chunk1Msg, dataProvider, tempDir); @@ -104,17 +102,25 @@ void testOutOfOrderMultiPodReassemblyAndSha256Verification() throws Exception { @Test void testSha256MismatchFailsFastAndCleansUp() { - byte[] chunk1Bytes = "corrupted-chunk".getBytes(StandardCharsets.UTF_8); - String sessionId = "trace-corrupted"; + final byte[] chunk1Bytes = "corrupted-chunk".getBytes(StandardCharsets.UTF_8); + final java.util.Date generation = com.google.udmi.util.JsonUtil.getDate("2026-09-27T12:05:00Z"); + + DiscoveryUpload startMsg = new DiscoveryUpload(); + startMsg.family = "bacnet"; + startMsg.generation = generation; + startMsg.event_no = 0; + assertNull(UploadProcessor.processDiscoveryUpload(envelope, startMsg, dataProvider, tempDir)); DiscoveryUpload chunk1Msg = new DiscoveryUpload(); - chunk1Msg.session_id = sessionId; + chunk1Msg.family = "bacnet"; + chunk1Msg.generation = generation; chunk1Msg.event_no = 1; chunk1Msg.data = Base64.getEncoder().encodeToString(chunk1Bytes); assertNull(UploadProcessor.processDiscoveryUpload(envelope, chunk1Msg, dataProvider, tempDir)); DiscoveryUpload eofMsg = new DiscoveryUpload(); - eofMsg.session_id = sessionId; + eofMsg.family = "bacnet"; + eofMsg.generation = generation; eofMsg.event_no = -2; eofMsg.sha256 = "0000000000000000000000000000000000000000000000000000000000000000"; diff --git a/udmis/src/test/java/com/google/bos/udmi/service/core/UufiProcessorTest.java b/udmis/src/test/java/com/google/bos/udmi/service/core/UufiProcessorTest.java index 69c3620b0a..317691edd0 100644 --- a/udmis/src/test/java/com/google/bos/udmi/service/core/UufiProcessorTest.java +++ b/udmis/src/test/java/com/google/bos/udmi/service/core/UufiProcessorTest.java @@ -147,6 +147,43 @@ public void inboundConfigRoutingTest() { assertNotNull(pointsetMap.get("points")); } + /** + * Test that inbound UUFI-wrapped download/blobset messages provision download blobs. + */ + @Test + public void inboundDownloadRoutingTest() { + Envelope innerEnvelope = new Envelope(); + innerEnvelope.subType = SubType.DOWNLOAD; + innerEnvelope.subFolder = SubFolder.BLOBSET; + innerEnvelope.deviceId = "dev-1"; + innerEnvelope.deviceRegistryId = "reg-1"; + + Map uufiWrapper = toMap(innerEnvelope); + Map innerPayload = Map.of( + "version", "1.5.2", + "timestamp", "2026-07-28T00:00:00Z", + "blobs", Map.of("_iot_endpoint_config", + Map.of("hostname", "localhost", "client_id", "r/reg-1/d/dev-1"))); + uufiWrapper.put("payload", innerPayload); + + Envelope transportEnvelope = new Envelope(); + transportEnvelope.source = "test-client"; + transportEnvelope.gatewayId = "uufi"; + + activeTestInstance(() -> getReverseDispatcher().publish( + new Bundle(transportEnvelope, uufiWrapper))); + + ArgumentCaptor endpointCaptor = + ArgumentCaptor.forClass(udmi.schema.EndpointConfiguration.class); + verify(provider, times(1)).provisionDownloadBlob( + org.mockito.ArgumentMatchers.eq("reg-1"), + org.mockito.ArgumentMatchers.eq("dev-1"), + org.mockito.ArgumentMatchers.eq("_iot_endpoint_config"), + endpointCaptor.capture()); + assertEquals("localhost", endpointCaptor.getValue().hostname); + assertEquals("r/reg-1/d/dev-1", endpointCaptor.getValue().client_id); + } + /** * Test that outbound system messages are correctly wrapped for UUFI clients. */ diff --git a/validator/src/main/java/com/google/daq/mqtt/sequencer/SequenceBase.java b/validator/src/main/java/com/google/daq/mqtt/sequencer/SequenceBase.java index dd2be903f2..97e93ea150 100644 --- a/validator/src/main/java/com/google/daq/mqtt/sequencer/SequenceBase.java +++ b/validator/src/main/java/com/google/daq/mqtt/sequencer/SequenceBase.java @@ -154,6 +154,7 @@ import udmi.schema.CapabilityValidationState.CapabilityResult; import udmi.schema.Config; import udmi.schema.DiscoveryEvents; +import udmi.schema.DiscoveryUpload; import udmi.schema.Entry; import udmi.schema.Envelope; import udmi.schema.Envelope.SubFolder; @@ -215,7 +216,8 @@ public class SequenceBase { private static final Map, SubFolder> CLASS_EVENT_SUBTYPE_MAP = ImmutableMap.of( SystemEvents.class, SubFolder.SYSTEM, PointsetEvents.class, SubFolder.POINTSET, - DiscoveryEvents.class, SubFolder.DISCOVERY + DiscoveryEvents.class, SubFolder.DISCOVERY, + DiscoveryUpload.class, SubFolder.DISCOVERY ); private static final Map> EXPECTED_UPDATES = ImmutableMap.of( SubType.CONFIG, Config.class, @@ -550,6 +552,11 @@ protected static String messageCaptureBase(SubType subType, SubFolder subFolder) return format("%s_%s", useType, useFolder); } + private static String messageCaptureBase(Class clazz) { + SubType subType = clazz == DiscoveryUpload.class ? SubType.UPLOAD : SubType.EVENTS; + return messageCaptureBase(subType, CLASS_EVENT_SUBTYPE_MAP.get(clazz)); + } + private static void emitSequenceResult(SequenceResult result, String bucket, String name, String stage, int score, int total, String message) { // TODO: Clean up this hack of using a class-wide static variable to store this information. @@ -2071,9 +2078,11 @@ private void handleDeviceMessage(Envelope envelope, Map message, switch (envelope.subType) { // These are echos of sent partial config messages, so do nothing. case CONFIG -> trace("Ignoring echo configTransaction " + transactionId); + case DOWNLOAD -> trace("Ignoring echo downloadTransaction " + transactionId); // State updates are handled as a monolithic block with a state reflector update. case STATE -> trace("Ignoring partial state update"); case EVENTS -> handleEventMessage(envelope, message); + case UPLOAD -> captureMessage(envelope, message); default -> info("Encountered unexpected subType " + envelope.subType); } } @@ -2412,7 +2421,7 @@ private void writeSequencerLog(Entry logEntry) { * @return Number of messages */ protected int countReceivedEvents(Class clazz) { - String messageKey = messageCaptureBase(SubType.EVENTS, CLASS_EVENT_SUBTYPE_MAP.get(clazz)); + String messageKey = messageCaptureBase(clazz); List> events = getCapturedMessagesList(getDeviceId(), messageKey); if (events == null) { return 0; @@ -2421,7 +2430,7 @@ protected int countReceivedEvents(Class clazz) { } protected List popReceivedEvents(Class clazz) { - String messageKey = messageCaptureBase(SubType.EVENTS, CLASS_EVENT_SUBTYPE_MAP.get(clazz)); + String messageKey = messageCaptureBase(clazz); List> events = getCaptureMap(getDeviceId()).remove(messageKey); if (events == null) { return ImmutableList.of(); @@ -2430,6 +2439,10 @@ protected List popReceivedEvents(Class clazz) { .collect(Collectors.toList()); } + protected void provisionDownload(SubFolder subBlock, Object data) { + reflector().publish(getDeviceId(), format("%s/download", subBlock.value()), stringify(data)); + } + protected void withAlternateClient(Runnable evaluator) { withAlternateClient(false, evaluator); } diff --git a/validator/src/main/java/com/google/daq/mqtt/sequencer/sequences/BlobsetSequences.java b/validator/src/main/java/com/google/daq/mqtt/sequencer/sequences/BlobsetSequences.java index 323f207fca..34edd63573 100644 --- a/validator/src/main/java/com/google/daq/mqtt/sequencer/sequences/BlobsetSequences.java +++ b/validator/src/main/java/com/google/daq/mqtt/sequencer/sequences/BlobsetSequences.java @@ -12,6 +12,7 @@ import static com.google.udmi.util.JsonUtil.getNowInstant; import static com.google.udmi.util.JsonUtil.isoConvert; import static com.google.udmi.util.JsonUtil.stringify; +import static com.google.udmi.util.JsonUtil.stringifyTerse; import static java.lang.String.format; import static org.junit.Assert.assertFalse; import static org.junit.Assert.assertNotEquals; @@ -23,6 +24,7 @@ import static udmi.schema.Category.BLOBSET_BLOB_FETCH; import static udmi.schema.Category.BLOBSET_BLOB_PARSE; import static udmi.schema.Category.BLOBSET_BLOB_RECEIVE; +import static udmi.schema.FeatureDiscovery.FeatureStage.ALPHA; import static udmi.schema.FeatureDiscovery.FeatureStage.PREVIEW; import com.google.daq.mqtt.sequencer.DefaultLogLevel; @@ -49,6 +51,7 @@ import udmi.schema.BlobUpdateTestingModel; import udmi.schema.BlobsetConfig; import udmi.schema.BlobsetConfig.SystemBlobsets; +import udmi.schema.BlobsetDownload; import udmi.schema.EndpointConfiguration; import udmi.schema.EndpointConfiguration.Protocol; import udmi.schema.EndpointConfiguration.Transport; @@ -95,7 +98,8 @@ private static String generateEndpointConfigClientId(String registryId) { : format(IOT_CORE_CLIENT_ID_FMT, projectId, cloudRegion, registryId, getDeviceId()); } - private static String endpointConfigPayload(String hostname, String registryId) { + private static EndpointConfiguration makeEndpointConfiguration(String hostname, + String registryId) { EndpointConfiguration endpointConfiguration = new EndpointConfiguration(); endpointConfiguration.protocol = Protocol.MQTT; endpointConfiguration.hostname = hostname; @@ -110,7 +114,11 @@ private static String endpointConfigPayload(String hostname, String registryId) authProvider.basic.username = endpointConfiguration.client_id; authProvider.basic.password = siteModel.getDevicePassword(getDeviceId()); } - return stringify(endpointConfiguration); + return endpointConfiguration; + } + + private static String endpointConfigPayload(String hostname, String registryId) { + return stringify(makeEndpointConfiguration(hostname, registryId)); } private void untilClearedRedirect() { @@ -225,6 +233,33 @@ public void endpoint_connection_success_reconnect() { untilClearedRedirect(); } + @Feature(stage = ALPHA, bucket = ENDPOINT_CONFIG) + @Summary("Check endpoint redirection via download/blobset channel.") + @Test(timeout = TWO_MINUTES_MS) + public void endpoint_connection_download() { + EndpointConfiguration endpointConfig = + makeEndpointConfiguration(getAlternateEndpointHostname(), registryId); + BlobsetDownload download = new BlobsetDownload(); + download.blobs = new HashMap<>(); + download.blobs.put(IOT_BLOB_KEY, endpointConfig); + provisionDownload(SubFolder.BLOBSET, download); + + BlobBlobsetConfig config = new BlobBlobsetConfig(); + config.url = SemanticValue.describe("endpoint download url", "download://" + IOT_BLOB_KEY); + config.phase = BlobPhase.FINAL; + config.generation = SemanticDate.describe("blob generation", new Date()); + config.sha256 = SemanticValue.describe("blob data hash", + sha256(stringifyTerse(endpointConfig))); + BlobsetConfig blobset = new BlobsetConfig(); + blobset.blobs = new HashMap<>(); + blobset.blobs.put(IOT_BLOB_KEY, config); + deviceConfig.blobset = blobset; + debug("blobset download config", stringify(deviceConfig.blobset)); + + untilSuccessfulRedirect(BlobPhase.FINAL); + untilClearedRedirect(); + } + @Feature(stage = PREVIEW, bucket = ENDPOINT_CONFIG) @Summary("Failed connection because of bad hash.") @ValidateSchema(SubFolder.BLOBSET) diff --git a/validator/src/main/java/com/google/daq/mqtt/sequencer/sequences/DiscoverySequences.java b/validator/src/main/java/com/google/daq/mqtt/sequencer/sequences/DiscoverySequences.java index bbb981a1d9..b852aebcd8 100644 --- a/validator/src/main/java/com/google/daq/mqtt/sequencer/sequences/DiscoverySequences.java +++ b/validator/src/main/java/com/google/daq/mqtt/sequencer/sequences/DiscoverySequences.java @@ -18,6 +18,7 @@ import static com.google.udmi.util.GeneralUtils.ifNullElse; import static com.google.udmi.util.GeneralUtils.ifTrueThen; import static com.google.udmi.util.GeneralUtils.joinOrNull; +import static com.google.udmi.util.GeneralUtils.sha256; import static com.google.udmi.util.JsonUtil.getNowInstant; import static com.google.udmi.util.JsonUtil.isoConvert; import static com.google.udmi.util.JsonUtil.stringifyTerse; @@ -54,9 +55,11 @@ import com.google.daq.mqtt.sequencer.semantic.SemanticDate; import com.google.daq.mqtt.sequencer.semantic.SemanticList; import com.google.daq.mqtt.util.providers.FamilyProvider; +import java.io.ByteArrayOutputStream; import java.time.Duration; import java.time.Instant; import java.util.ArrayList; +import java.util.Base64; import java.util.Date; import java.util.HashMap; import java.util.List; @@ -75,6 +78,7 @@ import udmi.schema.Bucket; import udmi.schema.DiscoveryConfig; import udmi.schema.DiscoveryEvents; +import udmi.schema.DiscoveryUpload; import udmi.schema.Enumerations; import udmi.schema.Enumerations.Depth; import udmi.schema.Envelope.SubFolder; @@ -734,6 +738,75 @@ public void scan_periodic_now_enumerate() { ifNotEmptyThrow(checkEnumeration(lastGenerationEvents, true), CSV_JOINER::join); } + @Test(timeout = TWO_MINUTES_MS) + @Feature(bucket = DISCOVERY_SCAN, stage = ALPHA, facets = SubFolder.DISCOVERY) + @Summary("Check results of a single scan with trace depth") + public void scan_depth_trace() { + initializeDiscovery(); + checkState(scanGeneration == null, "scanStartTime not null"); + Date scanStart = cleanInstantDate(Instant.now().minusSeconds(1)); + scanGeneration = scanStart.toInstant(); + popReceivedEvents(DiscoveryUpload.class); + configureScan(scanGeneration, null, DEFAULT_ENUMERATION, Depth.TRACE, null, null); + final Instant scanConfigured = getNowInstant(); + + waitUntil("scheduled scan active", WAITING_PERIOD, this::detailScanActive); + final Instant scanStarted = getNowInstant(); + long deltaStart = Math.abs(Duration.between(scanStarted, scanConfigured).toSeconds()); + checkThat("scan started at time", deltaStart <= SCAN_START_JITTER_SEC, + format("scan start %s %ss different from expected %s", isoConvert(scanStarted), deltaStart, + isoConvert(scanConfigured))); + + final Instant expectedFinish = scanStarted.plusSeconds(SCAN_DURATION_SEC); + waitUntil("scheduled scan complete", WAITING_PERIOD, this::detailScanStopped); + Instant scanFinished = getNowInstant(); + long deltaFinish = Math.abs(Duration.between(scanFinished, expectedFinish).toSeconds()); + checkThat("scan completed at time", deltaFinish <= SCAN_START_JITTER_SEC, + format("scan completed %ss different from expected %s", deltaFinish, + isoConvert(expectedFinish))); + + final int actualCount = deviceState.discovery.families.get(scanFamily).active_count; + checkThat("trace scan reported positive active_count", actualCount > 0); + sleepFor("discovery uploads synchronized", EVENT_JITTER_SLEEP_TIME); + + List events = popReceivedEvents(DiscoveryEvents.class); + checkThat("no discovery events emitted during trace scan", events.isEmpty()); + + List uploads = popReceivedEvents(DiscoveryUpload.class); + final int expectedUploads = actualCount + EVENT_MARKERS; + checkThat("received expected number of discovery uploads", + uploads.size() == expectedUploads, + format("expected %d uploads, received %d", expectedUploads, uploads.size())); + checkThat("all discovery uploads match scan family and generation", + uploads.stream().allMatch(u -> scanFamily.equals(u.family) + && dateEquals(u.generation, Date.from(scanGeneration)))); + + SortedSet eventNos = uploads.stream().map(u -> u.event_no) + .filter(Objects::nonNull).collect(Collectors.toCollection(TreeSet::new)); + checkThat("received all unique upload event numbers", eventNos.size() == expectedUploads); + final Integer endEventNo = eventNos.removeFirst(); + checkThat("received proper upload start marker", eventNos.getFirst() == 0); + checkThat("received proper last upload chunk", eventNos.getLast() == actualCount); + checkThat("received proper upload termination marker", endEventNo == -(actualCount + 1)); + + Map uploadsByEventNo = uploads.stream() + .collect(Collectors.toMap(u -> u.event_no, Function.identity())); + ByteArrayOutputStream assembled = new ByteArrayOutputStream(); + for (int chunkNo = 1; chunkNo <= actualCount; chunkNo++) { + DiscoveryUpload chunk = uploadsByEventNo.get(chunkNo); + checkThat(format("upload chunk %d data present", chunkNo), + chunk != null && chunk.data != null); + assembled.writeBytes(Base64.getDecoder().decode(chunk.data)); + } + + DiscoveryUpload eofUpload = uploadsByEventNo.get(endEventNo); + checkThat("upload termination marker includes sha256", eofUpload.sha256 != null); + String actualSha256 = sha256(assembled.toByteArray()); + checkThat("assembled upload matches termination marker sha256", + eofUpload.sha256.equalsIgnoreCase(actualSha256), + format("expected %s, got %s", eofUpload.sha256, actualSha256)); + } + private void initializeDiscovery() { initializeFamilies(); deviceConfig.discovery = new DiscoveryConfig(); diff --git a/validator/src/main/java/com/google/daq/mqtt/validator/Validator.java b/validator/src/main/java/com/google/daq/mqtt/validator/Validator.java index f091551df5..08eddbbb29 100644 --- a/validator/src/main/java/com/google/daq/mqtt/validator/Validator.java +++ b/validator/src/main/java/com/google/daq/mqtt/validator/Validator.java @@ -154,12 +154,14 @@ public class Validator { private static final String CONFIG_CATEGORY = "config"; private static final Set INTERESTING_TYPES = ImmutableSet.of( SubType.EVENTS.value(), - SubType.STATE.value()); + SubType.STATE.value(), + SubType.UPLOAD.value()); private static final Set IGNORE_FOLDERS = ImmutableSet.of( SubFolder.ERROR.value()); private static final Set LAST_SEEN_SUBTYPES = ImmutableSet.of( SubType.EVENTS, - SubType.STATE); + SubType.STATE, + SubType.UPLOAD); private static final long DEFAULT_INTERVAL_SEC = 60; private static final long REPORTS_PER_SEC = 10;