diff --git a/homeassistant/components/sandbox_v2/_proto/sandbox_v2_pb2.py b/homeassistant/components/sandbox_v2/_proto/sandbox_v2_pb2.py index 1ee795d6725..41eef0c37f8 100644 --- a/homeassistant/components/sandbox_v2/_proto/sandbox_v2_pb2.py +++ b/homeassistant/components/sandbox_v2/_proto/sandbox_v2_pb2.py @@ -25,7 +25,7 @@ _sym_db = _symbol_database.Default() from google.protobuf import struct_pb2 as google_dot_protobuf_dot_struct__pb2 -DESCRIPTOR = _descriptor_pool.Default().AddSerializedFile(b'\n\x10sandbox_v2.proto\x12\nsandbox_v2\x1a\x1cgoogle/protobuf/struct.proto\"f\n\x05\x46rame\x12\n\n\x02id\x18\x01 \x01(\r\x12\x0c\n\x04type\x18\x02 \x01(\t\x12\x11\n\x07request\x18\x03 \x01(\x0cH\x00\x12(\n\x08response\x18\x04 \x01(\x0b\x32\x14.sandbox_v2.ResponseH\x00\x42\x06\n\x04\x62ody\"H\n\x08Response\x12\n\n\x02ok\x18\x01 \x01(\x08\x12\x0e\n\x06result\x18\x02 \x01(\x0c\x12 \n\x05\x65rror\x18\x03 \x01(\x0b\x32\x11.sandbox_v2.Error\"c\n\x05\x45rror\x12\x0f\n\x07message\x18\x01 \x01(\t\x12\x0c\n\x04type\x18\x02 \x01(\t\x12)\n\x07invalid\x18\x03 \x03(\x0b\x32\x18.sandbox_v2.InvalidError\x12\x10\n\x08multiple\x18\x04 \x01(\x08\"-\n\x0cInvalidError\x12\x0f\n\x07message\x18\x01 \x01(\t\x12\x0c\n\x04path\x18\x02 \x03(\t\"(\n\nDevicePair\x12\x0b\n\x03key\x18\x01 \x01(\t\x12\r\n\x05value\x18\x02 \x01(\t\"\xd5\x03\n\nDeviceInfo\x12+\n\x0bidentifiers\x18\x01 \x03(\x0b\x32\x16.sandbox_v2.DevicePair\x12+\n\x0b\x63onnections\x18\x02 \x03(\x0b\x32\x16.sandbox_v2.DevicePair\x12/\n\nvia_device\x18\x03 \x01(\x0b\x32\x16.sandbox_v2.DevicePairH\x00\x88\x01\x01\x12\x12\n\nentry_type\x18\x04 \x01(\t\x12\x0c\n\x04name\x18\x05 \x01(\t\x12\x14\n\x0cmanufacturer\x18\x06 \x01(\t\x12\r\n\x05model\x18\x07 \x01(\t\x12\x10\n\x08model_id\x18\x08 \x01(\t\x12\x12\n\nsw_version\x18\t \x01(\t\x12\x12\n\nhw_version\x18\n \x01(\t\x12\x15\n\rserial_number\x18\x0b \x01(\t\x12\x16\n\x0esuggested_area\x18\x0c \x01(\t\x12\x19\n\x11\x63onfiguration_url\x18\r \x01(\t\x12\x14\n\x0c\x64\x65\x66\x61ult_name\x18\x0e \x01(\t\x12\x1c\n\x14\x64\x65\x66\x61ult_manufacturer\x18\x0f \x01(\t\x12\x15\n\rdefault_model\x18\x10 \x01(\t\x12\x17\n\x0ftranslation_key\x18\x11 \x01(\tB\r\n\x0b_via_device\"\xec\x01\n\nEntrySetup\x12\x10\n\x08\x65ntry_id\x18\x01 \x01(\t\x12\x0e\n\x06\x64omain\x18\x02 \x01(\t\x12\r\n\x05title\x18\x03 \x01(\t\x12%\n\x04\x64\x61ta\x18\x04 \x01(\x0b\x32\x17.google.protobuf.Struct\x12(\n\x07options\x18\x05 \x01(\x0b\x32\x17.google.protobuf.Struct\x12\x0e\n\x06source\x18\x06 \x01(\t\x12\x16\n\tunique_id\x18\x07 \x01(\tH\x00\x88\x01\x01\x12\x0f\n\x07version\x18\x08 \x01(\x05\x12\x15\n\rminor_version\x18\t \x01(\x05\x42\x0c\n\n_unique_id\">\n\x10\x45ntrySetupResult\x12\n\n\x02ok\x18\x01 \x01(\x08\x12\x13\n\x06reason\x18\x02 \x01(\tH\x00\x88\x01\x01\x42\t\n\x07_reason\"\x1f\n\x0b\x45ntryUnload\x12\x10\n\x08\x65ntry_id\x18\x01 \x01(\t\"\x1f\n\x11\x45ntryUnloadResult\x12\n\n\x02ok\x18\x01 \x01(\x08\"\xc7\x01\n\x0b\x43\x61llService\x12\x0e\n\x06\x64omain\x18\x01 \x01(\t\x12\x0f\n\x07service\x18\x02 \x01(\t\x12\'\n\x06target\x18\x03 \x01(\x0b\x32\x17.google.protobuf.Struct\x12-\n\x0cservice_data\x18\x04 \x01(\x0b\x32\x17.google.protobuf.Struct\x12\x17\n\ncontext_id\x18\x05 \x01(\tH\x00\x88\x01\x01\x12\x17\n\x0freturn_response\x18\x06 \x01(\x08\x42\r\n\x0b_context_id\"8\n\x0fServiceResponse\x12%\n\x04\x64\x61ta\x18\x01 \x01(\x0b\x32\x17.google.protobuf.Struct\"T\n\x11\x43\x61llServiceResult\x12\x32\n\x08response\x18\x01 \x01(\x0b\x32\x1b.sandbox_v2.ServiceResponseH\x00\x88\x01\x01\x42\x0b\n\t_response\"\n\n\x08Shutdown\"u\n\x0eShutdownResult\x12\n\n\x02ok\x18\x01 \x01(\x08\x12\x10\n\x08unloaded\x18\x02 \x01(\x05\x12\x33\n\rrestore_state\x18\x03 \x01(\x0b\x32\x17.google.protobuf.StructH\x00\x88\x01\x01\x42\x10\n\x0e_restore_state\"\x06\n\x04Ping\"\x1a\n\nPingResult\x12\x0c\n\x04pong\x18\x01 \x01(\t\"\x07\n\x05Ready\"l\n\x08\x46lowInit\x12\x0f\n\x07handler\x18\x01 \x01(\t\x12(\n\x07\x63ontext\x18\x02 \x01(\x0b\x32\x17.google.protobuf.Struct\x12%\n\x04\x64\x61ta\x18\x03 \x01(\x0b\x32\x17.google.protobuf.Struct\"H\n\x08\x46lowStep\x12\x0f\n\x07\x66low_id\x18\x01 \x01(\t\x12+\n\nuser_input\x18\x02 \x01(\x0b\x32\x17.google.protobuf.Struct\"\x1c\n\tFlowAbort\x12\x0f\n\x07\x66low_id\x18\x01 \x01(\t\"\x11\n\x0f\x46lowAbortResult\"\xa9\x05\n\nFlowResult\x12\x0c\n\x04type\x18\x01 \x01(\t\x12\x14\n\x07\x66low_id\x18\x02 \x01(\tH\x00\x88\x01\x01\x12\x14\n\x07handler\x18\x03 \x01(\tH\x01\x88\x01\x01\x12\x14\n\x07step_id\x18\x04 \x01(\tH\x02\x88\x01\x01\x12\x13\n\x06reason\x18\x05 \x01(\tH\x03\x88\x01\x01\x12\x12\n\x05title\x18\x06 \x01(\tH\x04\x88\x01\x01\x12\x18\n\x0b\x64\x65scription\x18\x07 \x01(\tH\x05\x88\x01\x01\x12\x16\n\tlast_step\x18\x08 \x01(\x08H\x06\x88\x01\x01\x12\x14\n\x07preview\x18\t \x01(\tH\x07\x88\x01\x01\x12\x14\n\x07version\x18\n \x01(\x05H\x08\x88\x01\x01\x12\x1a\n\rminor_version\x18\x0b \x01(\x05H\t\x88\x01\x01\x12%\n\x04\x64\x61ta\x18\x0c \x01(\x0b\x32\x17.google.protobuf.Struct\x12(\n\x07options\x18\r \x01(\x0b\x32\x17.google.protobuf.Struct\x12\'\n\x06\x65rrors\x18\x0e \x01(\x0b\x32\x17.google.protobuf.Struct\x12\x39\n\x18\x64\x65scription_placeholders\x18\x0f \x01(\x0b\x32\x17.google.protobuf.Struct\x12(\n\x07\x63ontext\x18\x10 \x01(\x0b\x32\x17.google.protobuf.Struct\x12/\n\x0b\x64\x61ta_schema\x18\x11 \x01(\x0b\x32\x1a.google.protobuf.ListValue\x12\x17\n\x0fhas_data_schema\x18\x12 \x01(\x08\x42\n\n\x08_flow_idB\n\n\x08_handlerB\n\n\x08_step_idB\t\n\x07_reasonB\x08\n\x06_titleB\x0e\n\x0c_descriptionB\x0c\n\n_last_stepB\n\n\x08_previewB\n\n\x08_versionB\x10\n\x0e_minor_version\"\x90\x03\n\nEntityInfo\x12<\n\x0b\x64\x65scription\x18\x01 \x01(\x0b\x32\".sandbox_v2.EntityInfo.DescriptionH\x00\x88\x01\x01\x12\x30\n\x0b\x64\x65vice_info\x18\x02 \x01(\x0b\x32\x16.sandbox_v2.DeviceInfoH\x01\x88\x01\x01\x1a\xf1\x01\n\x0b\x44\x65scription\x12\x11\n\x04name\x18\x01 \x01(\tH\x00\x88\x01\x01\x12\x11\n\x04icon\x18\x02 \x01(\tH\x01\x88\x01\x01\x12\x1c\n\x0f\x65ntity_category\x18\x03 \x01(\tH\x02\x88\x01\x01\x12\x19\n\x0c\x64\x65vice_class\x18\x04 \x01(\tH\x03\x88\x01\x01\x12\x1a\n\x12supported_features\x18\x05 \x01(\x05\x12\x1c\n\x0ftranslation_key\x18\x06 \x01(\tH\x04\x88\x01\x01\x42\x07\n\x05_nameB\x07\n\x05_iconB\x12\n\x10_entity_categoryB\x0f\n\r_device_classB\x12\n\x10_translation_keyB\x0e\n\x0c_descriptionB\x0e\n\x0c_device_info\"\x88\x01\n\x0cInitialState\x12\x12\n\x05state\x18\x01 \x01(\tH\x00\x88\x01\x01\x12-\n\x0c\x63\x61pabilities\x18\x02 \x01(\x0b\x32\x17.google.protobuf.Struct\x12+\n\nattributes\x18\x03 \x01(\x0b\x32\x17.google.protobuf.StructB\x08\n\x06_state\"\xe0\x01\n\x11\x45ntityDescription\x12\x10\n\x08\x65ntry_id\x18\x01 \x01(\t\x12\x0e\n\x06\x64omain\x18\x02 \x01(\t\x12\x19\n\x11sandbox_entity_id\x18\x03 \x01(\t\x12\x16\n\tunique_id\x18\x04 \x01(\tH\x00\x88\x01\x01\x12\x17\n\x0fhas_entity_name\x18\x05 \x01(\x08\x12$\n\x04info\x18\x06 \x01(\x0b\x32\x16.sandbox_v2.EntityInfo\x12)\n\x07initial\x18\x07 \x01(\x0b\x32\x18.sandbox_v2.InitialStateB\x0c\n\n_unique_id\")\n\x14RegisterEntityResult\x12\x11\n\tentity_id\x18\x01 \x01(\t\"-\n\x10UnregisterEntity\x12\x19\n\x11sandbox_entity_id\x18\x01 \x01(\t\"$\n\x16UnregisterEntityResult\x12\n\n\x02ok\x18\x01 \x01(\x08\"\x9c\x01\n\x0cStateChanged\x12\x19\n\x11sandbox_entity_id\x18\x01 \x01(\t\x12\x12\n\x05state\x18\x02 \x01(\tH\x00\x88\x01\x01\x12+\n\nattributes\x18\x03 \x01(\x0b\x32\x17.google.protobuf.Struct\x12\x17\n\ncontext_id\x18\x04 \x01(\tH\x01\x88\x01\x01\x42\x08\n\x06_stateB\r\n\x0b_context_id\"y\n\x0fRegisterService\x12\x0e\n\x06\x64omain\x18\x01 \x01(\t\x12\x0f\n\x07service\x18\x02 \x01(\t\x12\x19\n\x11supports_response\x18\x03 \x01(\t\x12*\n\x06schema\x18\x04 \x01(\x0b\x32\x1a.google.protobuf.ListValue\"6\n\x15RegisterServiceResult\x12\n\n\x02ok\x18\x01 \x01(\x08\x12\x11\n\tinstalled\x18\x02 \x01(\x08\"4\n\x11UnregisterService\x12\x0e\n\x06\x64omain\x18\x01 \x01(\t\x12\x0f\n\x07service\x18\x02 \x01(\t\"6\n\x17UnregisterServiceResult\x12\n\n\x02ok\x18\x01 \x01(\x08\x12\x0f\n\x07removed\x18\x02 \x01(\x08\"t\n\tFireEvent\x12\x12\n\nevent_type\x18\x01 \x01(\t\x12+\n\nevent_data\x18\x02 \x01(\x0b\x32\x17.google.protobuf.Struct\x12\x17\n\ncontext_id\x18\x03 \x01(\tH\x00\x88\x01\x01\x42\r\n\x0b_context_id\"\x18\n\tStoreLoad\x12\x0b\n\x03key\x18\x01 \x01(\t\"F\n\x0fStoreLoadResult\x12*\n\x04\x64\x61ta\x18\x01 \x01(\x0b\x32\x17.google.protobuf.StructH\x00\x88\x01\x01\x42\x07\n\x05_data\"?\n\tStoreSave\x12\x0b\n\x03key\x18\x01 \x01(\t\x12%\n\x04\x64\x61ta\x18\x02 \x01(\x0b\x32\x17.google.protobuf.Struct\"\x1d\n\x0fStoreSaveResult\x12\n\n\x02ok\x18\x01 \x01(\x08\"\x1a\n\x0bStoreRemove\x12\x0b\n\x03key\x18\x01 \x01(\t\"\x1f\n\x11StoreRemoveResult\x12\n\n\x02ok\x18\x01 \x01(\x08\x62\x06proto3') +DESCRIPTOR = _descriptor_pool.Default().AddSerializedFile(b'\n\x10sandbox_v2.proto\x12\nsandbox_v2\x1a\x1cgoogle/protobuf/struct.proto\"f\n\x05\x46rame\x12\n\n\x02id\x18\x01 \x01(\r\x12\x0c\n\x04type\x18\x02 \x01(\t\x12\x11\n\x07request\x18\x03 \x01(\x0cH\x00\x12(\n\x08response\x18\x04 \x01(\x0b\x32\x14.sandbox_v2.ResponseH\x00\x42\x06\n\x04\x62ody\"H\n\x08Response\x12\n\n\x02ok\x18\x01 \x01(\x08\x12\x0e\n\x06result\x18\x02 \x01(\x0c\x12 \n\x05\x65rror\x18\x03 \x01(\x0b\x32\x11.sandbox_v2.Error\"c\n\x05\x45rror\x12\x0f\n\x07message\x18\x01 \x01(\t\x12\x0c\n\x04type\x18\x02 \x01(\t\x12)\n\x07invalid\x18\x03 \x03(\x0b\x32\x18.sandbox_v2.InvalidError\x12\x10\n\x08multiple\x18\x04 \x01(\x08\"-\n\x0cInvalidError\x12\x0f\n\x07message\x18\x01 \x01(\t\x12\x0c\n\x04path\x18\x02 \x03(\t\"(\n\nDevicePair\x12\x0b\n\x03key\x18\x01 \x01(\t\x12\r\n\x05value\x18\x02 \x01(\t\"\xd5\x03\n\nDeviceInfo\x12+\n\x0bidentifiers\x18\x01 \x03(\x0b\x32\x16.sandbox_v2.DevicePair\x12+\n\x0b\x63onnections\x18\x02 \x03(\x0b\x32\x16.sandbox_v2.DevicePair\x12/\n\nvia_device\x18\x03 \x01(\x0b\x32\x16.sandbox_v2.DevicePairH\x00\x88\x01\x01\x12\x12\n\nentry_type\x18\x04 \x01(\t\x12\x0c\n\x04name\x18\x05 \x01(\t\x12\x14\n\x0cmanufacturer\x18\x06 \x01(\t\x12\r\n\x05model\x18\x07 \x01(\t\x12\x10\n\x08model_id\x18\x08 \x01(\t\x12\x12\n\nsw_version\x18\t \x01(\t\x12\x12\n\nhw_version\x18\n \x01(\t\x12\x15\n\rserial_number\x18\x0b \x01(\t\x12\x16\n\x0esuggested_area\x18\x0c \x01(\t\x12\x19\n\x11\x63onfiguration_url\x18\r \x01(\t\x12\x14\n\x0c\x64\x65\x66\x61ult_name\x18\x0e \x01(\t\x12\x1c\n\x14\x64\x65\x66\x61ult_manufacturer\x18\x0f \x01(\t\x12\x15\n\rdefault_model\x18\x10 \x01(\t\x12\x17\n\x0ftranslation_key\x18\x11 \x01(\tB\r\n\x0b_via_device\"h\n\x11IntegrationSource\x12\x0c\n\x04kind\x18\x01 \x01(\t\x12\x0b\n\x03url\x18\x02 \x01(\t\x12\x0b\n\x03ref\x18\x03 \x01(\t\x12\x0b\n\x03tag\x18\x04 \x01(\t\x12\x0e\n\x06\x64omain\x18\x05 \x01(\t\x12\x0e\n\x06subdir\x18\x06 \x01(\t\"\xa7\x02\n\nEntrySetup\x12\x10\n\x08\x65ntry_id\x18\x01 \x01(\t\x12\x0e\n\x06\x64omain\x18\x02 \x01(\t\x12\r\n\x05title\x18\x03 \x01(\t\x12%\n\x04\x64\x61ta\x18\x04 \x01(\x0b\x32\x17.google.protobuf.Struct\x12(\n\x07options\x18\x05 \x01(\x0b\x32\x17.google.protobuf.Struct\x12\x0e\n\x06source\x18\x06 \x01(\t\x12\x16\n\tunique_id\x18\x07 \x01(\tH\x00\x88\x01\x01\x12\x0f\n\x07version\x18\x08 \x01(\x05\x12\x15\n\rminor_version\x18\t \x01(\x05\x12\x39\n\x12integration_source\x18\n \x01(\x0b\x32\x1d.sandbox_v2.IntegrationSourceB\x0c\n\n_unique_id\">\n\x10\x45ntrySetupResult\x12\n\n\x02ok\x18\x01 \x01(\x08\x12\x13\n\x06reason\x18\x02 \x01(\tH\x00\x88\x01\x01\x42\t\n\x07_reason\"\x1f\n\x0b\x45ntryUnload\x12\x10\n\x08\x65ntry_id\x18\x01 \x01(\t\"\x1f\n\x11\x45ntryUnloadResult\x12\n\n\x02ok\x18\x01 \x01(\x08\"\xc7\x01\n\x0b\x43\x61llService\x12\x0e\n\x06\x64omain\x18\x01 \x01(\t\x12\x0f\n\x07service\x18\x02 \x01(\t\x12\'\n\x06target\x18\x03 \x01(\x0b\x32\x17.google.protobuf.Struct\x12-\n\x0cservice_data\x18\x04 \x01(\x0b\x32\x17.google.protobuf.Struct\x12\x17\n\ncontext_id\x18\x05 \x01(\tH\x00\x88\x01\x01\x12\x17\n\x0freturn_response\x18\x06 \x01(\x08\x42\r\n\x0b_context_id\"8\n\x0fServiceResponse\x12%\n\x04\x64\x61ta\x18\x01 \x01(\x0b\x32\x17.google.protobuf.Struct\"T\n\x11\x43\x61llServiceResult\x12\x32\n\x08response\x18\x01 \x01(\x0b\x32\x1b.sandbox_v2.ServiceResponseH\x00\x88\x01\x01\x42\x0b\n\t_response\"\n\n\x08Shutdown\"u\n\x0eShutdownResult\x12\n\n\x02ok\x18\x01 \x01(\x08\x12\x10\n\x08unloaded\x18\x02 \x01(\x05\x12\x33\n\rrestore_state\x18\x03 \x01(\x0b\x32\x17.google.protobuf.StructH\x00\x88\x01\x01\x42\x10\n\x0e_restore_state\"\x06\n\x04Ping\"\x1a\n\nPingResult\x12\x0c\n\x04pong\x18\x01 \x01(\t\"\x07\n\x05Ready\"l\n\x08\x46lowInit\x12\x0f\n\x07handler\x18\x01 \x01(\t\x12(\n\x07\x63ontext\x18\x02 \x01(\x0b\x32\x17.google.protobuf.Struct\x12%\n\x04\x64\x61ta\x18\x03 \x01(\x0b\x32\x17.google.protobuf.Struct\"H\n\x08\x46lowStep\x12\x0f\n\x07\x66low_id\x18\x01 \x01(\t\x12+\n\nuser_input\x18\x02 \x01(\x0b\x32\x17.google.protobuf.Struct\"\x1c\n\tFlowAbort\x12\x0f\n\x07\x66low_id\x18\x01 \x01(\t\"\x11\n\x0f\x46lowAbortResult\"\xa9\x05\n\nFlowResult\x12\x0c\n\x04type\x18\x01 \x01(\t\x12\x14\n\x07\x66low_id\x18\x02 \x01(\tH\x00\x88\x01\x01\x12\x14\n\x07handler\x18\x03 \x01(\tH\x01\x88\x01\x01\x12\x14\n\x07step_id\x18\x04 \x01(\tH\x02\x88\x01\x01\x12\x13\n\x06reason\x18\x05 \x01(\tH\x03\x88\x01\x01\x12\x12\n\x05title\x18\x06 \x01(\tH\x04\x88\x01\x01\x12\x18\n\x0b\x64\x65scription\x18\x07 \x01(\tH\x05\x88\x01\x01\x12\x16\n\tlast_step\x18\x08 \x01(\x08H\x06\x88\x01\x01\x12\x14\n\x07preview\x18\t \x01(\tH\x07\x88\x01\x01\x12\x14\n\x07version\x18\n \x01(\x05H\x08\x88\x01\x01\x12\x1a\n\rminor_version\x18\x0b \x01(\x05H\t\x88\x01\x01\x12%\n\x04\x64\x61ta\x18\x0c \x01(\x0b\x32\x17.google.protobuf.Struct\x12(\n\x07options\x18\r \x01(\x0b\x32\x17.google.protobuf.Struct\x12\'\n\x06\x65rrors\x18\x0e \x01(\x0b\x32\x17.google.protobuf.Struct\x12\x39\n\x18\x64\x65scription_placeholders\x18\x0f \x01(\x0b\x32\x17.google.protobuf.Struct\x12(\n\x07\x63ontext\x18\x10 \x01(\x0b\x32\x17.google.protobuf.Struct\x12/\n\x0b\x64\x61ta_schema\x18\x11 \x01(\x0b\x32\x1a.google.protobuf.ListValue\x12\x17\n\x0fhas_data_schema\x18\x12 \x01(\x08\x42\n\n\x08_flow_idB\n\n\x08_handlerB\n\n\x08_step_idB\t\n\x07_reasonB\x08\n\x06_titleB\x0e\n\x0c_descriptionB\x0c\n\n_last_stepB\n\n\x08_previewB\n\n\x08_versionB\x10\n\x0e_minor_version\"\x90\x03\n\nEntityInfo\x12<\n\x0b\x64\x65scription\x18\x01 \x01(\x0b\x32\".sandbox_v2.EntityInfo.DescriptionH\x00\x88\x01\x01\x12\x30\n\x0b\x64\x65vice_info\x18\x02 \x01(\x0b\x32\x16.sandbox_v2.DeviceInfoH\x01\x88\x01\x01\x1a\xf1\x01\n\x0b\x44\x65scription\x12\x11\n\x04name\x18\x01 \x01(\tH\x00\x88\x01\x01\x12\x11\n\x04icon\x18\x02 \x01(\tH\x01\x88\x01\x01\x12\x1c\n\x0f\x65ntity_category\x18\x03 \x01(\tH\x02\x88\x01\x01\x12\x19\n\x0c\x64\x65vice_class\x18\x04 \x01(\tH\x03\x88\x01\x01\x12\x1a\n\x12supported_features\x18\x05 \x01(\x05\x12\x1c\n\x0ftranslation_key\x18\x06 \x01(\tH\x04\x88\x01\x01\x42\x07\n\x05_nameB\x07\n\x05_iconB\x12\n\x10_entity_categoryB\x0f\n\r_device_classB\x12\n\x10_translation_keyB\x0e\n\x0c_descriptionB\x0e\n\x0c_device_info\"\x88\x01\n\x0cInitialState\x12\x12\n\x05state\x18\x01 \x01(\tH\x00\x88\x01\x01\x12-\n\x0c\x63\x61pabilities\x18\x02 \x01(\x0b\x32\x17.google.protobuf.Struct\x12+\n\nattributes\x18\x03 \x01(\x0b\x32\x17.google.protobuf.StructB\x08\n\x06_state\"\xe0\x01\n\x11\x45ntityDescription\x12\x10\n\x08\x65ntry_id\x18\x01 \x01(\t\x12\x0e\n\x06\x64omain\x18\x02 \x01(\t\x12\x19\n\x11sandbox_entity_id\x18\x03 \x01(\t\x12\x16\n\tunique_id\x18\x04 \x01(\tH\x00\x88\x01\x01\x12\x17\n\x0fhas_entity_name\x18\x05 \x01(\x08\x12$\n\x04info\x18\x06 \x01(\x0b\x32\x16.sandbox_v2.EntityInfo\x12)\n\x07initial\x18\x07 \x01(\x0b\x32\x18.sandbox_v2.InitialStateB\x0c\n\n_unique_id\")\n\x14RegisterEntityResult\x12\x11\n\tentity_id\x18\x01 \x01(\t\"-\n\x10UnregisterEntity\x12\x19\n\x11sandbox_entity_id\x18\x01 \x01(\t\"$\n\x16UnregisterEntityResult\x12\n\n\x02ok\x18\x01 \x01(\x08\"\x9c\x01\n\x0cStateChanged\x12\x19\n\x11sandbox_entity_id\x18\x01 \x01(\t\x12\x12\n\x05state\x18\x02 \x01(\tH\x00\x88\x01\x01\x12+\n\nattributes\x18\x03 \x01(\x0b\x32\x17.google.protobuf.Struct\x12\x17\n\ncontext_id\x18\x04 \x01(\tH\x01\x88\x01\x01\x42\x08\n\x06_stateB\r\n\x0b_context_id\"y\n\x0fRegisterService\x12\x0e\n\x06\x64omain\x18\x01 \x01(\t\x12\x0f\n\x07service\x18\x02 \x01(\t\x12\x19\n\x11supports_response\x18\x03 \x01(\t\x12*\n\x06schema\x18\x04 \x01(\x0b\x32\x1a.google.protobuf.ListValue\"6\n\x15RegisterServiceResult\x12\n\n\x02ok\x18\x01 \x01(\x08\x12\x11\n\tinstalled\x18\x02 \x01(\x08\"4\n\x11UnregisterService\x12\x0e\n\x06\x64omain\x18\x01 \x01(\t\x12\x0f\n\x07service\x18\x02 \x01(\t\"6\n\x17UnregisterServiceResult\x12\n\n\x02ok\x18\x01 \x01(\x08\x12\x0f\n\x07removed\x18\x02 \x01(\x08\"t\n\tFireEvent\x12\x12\n\nevent_type\x18\x01 \x01(\t\x12+\n\nevent_data\x18\x02 \x01(\x0b\x32\x17.google.protobuf.Struct\x12\x17\n\ncontext_id\x18\x03 \x01(\tH\x00\x88\x01\x01\x42\r\n\x0b_context_id\"\x18\n\tStoreLoad\x12\x0b\n\x03key\x18\x01 \x01(\t\"F\n\x0fStoreLoadResult\x12*\n\x04\x64\x61ta\x18\x01 \x01(\x0b\x32\x17.google.protobuf.StructH\x00\x88\x01\x01\x42\x07\n\x05_data\"?\n\tStoreSave\x12\x0b\n\x03key\x18\x01 \x01(\t\x12%\n\x04\x64\x61ta\x18\x02 \x01(\x0b\x32\x17.google.protobuf.Struct\"\x1d\n\x0fStoreSaveResult\x12\n\n\x02ok\x18\x01 \x01(\x08\"\x1a\n\x0bStoreRemove\x12\x0b\n\x03key\x18\x01 \x01(\t\"\x1f\n\x11StoreRemoveResult\x12\n\n\x02ok\x18\x01 \x01(\x08\x62\x06proto3') _globals = globals() _builder.BuildMessageAndEnumDescriptors(DESCRIPTOR, _globals) @@ -44,76 +44,78 @@ if not _descriptor._USE_C_DESCRIPTORS: _globals['_DEVICEPAIR']._serialized_end=428 _globals['_DEVICEINFO']._serialized_start=431 _globals['_DEVICEINFO']._serialized_end=900 - _globals['_ENTRYSETUP']._serialized_start=903 - _globals['_ENTRYSETUP']._serialized_end=1139 - _globals['_ENTRYSETUPRESULT']._serialized_start=1141 - _globals['_ENTRYSETUPRESULT']._serialized_end=1203 - _globals['_ENTRYUNLOAD']._serialized_start=1205 - _globals['_ENTRYUNLOAD']._serialized_end=1236 - _globals['_ENTRYUNLOADRESULT']._serialized_start=1238 - _globals['_ENTRYUNLOADRESULT']._serialized_end=1269 - _globals['_CALLSERVICE']._serialized_start=1272 - _globals['_CALLSERVICE']._serialized_end=1471 - _globals['_SERVICERESPONSE']._serialized_start=1473 - _globals['_SERVICERESPONSE']._serialized_end=1529 - _globals['_CALLSERVICERESULT']._serialized_start=1531 - _globals['_CALLSERVICERESULT']._serialized_end=1615 - _globals['_SHUTDOWN']._serialized_start=1617 - _globals['_SHUTDOWN']._serialized_end=1627 - _globals['_SHUTDOWNRESULT']._serialized_start=1629 - _globals['_SHUTDOWNRESULT']._serialized_end=1746 - _globals['_PING']._serialized_start=1748 - _globals['_PING']._serialized_end=1754 - _globals['_PINGRESULT']._serialized_start=1756 - _globals['_PINGRESULT']._serialized_end=1782 - _globals['_READY']._serialized_start=1784 - _globals['_READY']._serialized_end=1791 - _globals['_FLOWINIT']._serialized_start=1793 - _globals['_FLOWINIT']._serialized_end=1901 - _globals['_FLOWSTEP']._serialized_start=1903 - _globals['_FLOWSTEP']._serialized_end=1975 - _globals['_FLOWABORT']._serialized_start=1977 - _globals['_FLOWABORT']._serialized_end=2005 - _globals['_FLOWABORTRESULT']._serialized_start=2007 - _globals['_FLOWABORTRESULT']._serialized_end=2024 - _globals['_FLOWRESULT']._serialized_start=2027 - _globals['_FLOWRESULT']._serialized_end=2708 - _globals['_ENTITYINFO']._serialized_start=2711 - _globals['_ENTITYINFO']._serialized_end=3111 - _globals['_ENTITYINFO_DESCRIPTION']._serialized_start=2838 - _globals['_ENTITYINFO_DESCRIPTION']._serialized_end=3079 - _globals['_INITIALSTATE']._serialized_start=3114 - _globals['_INITIALSTATE']._serialized_end=3250 - _globals['_ENTITYDESCRIPTION']._serialized_start=3253 - _globals['_ENTITYDESCRIPTION']._serialized_end=3477 - _globals['_REGISTERENTITYRESULT']._serialized_start=3479 - _globals['_REGISTERENTITYRESULT']._serialized_end=3520 - _globals['_UNREGISTERENTITY']._serialized_start=3522 - _globals['_UNREGISTERENTITY']._serialized_end=3567 - _globals['_UNREGISTERENTITYRESULT']._serialized_start=3569 - _globals['_UNREGISTERENTITYRESULT']._serialized_end=3605 - _globals['_STATECHANGED']._serialized_start=3608 - _globals['_STATECHANGED']._serialized_end=3764 - _globals['_REGISTERSERVICE']._serialized_start=3766 - _globals['_REGISTERSERVICE']._serialized_end=3887 - _globals['_REGISTERSERVICERESULT']._serialized_start=3889 - _globals['_REGISTERSERVICERESULT']._serialized_end=3943 - _globals['_UNREGISTERSERVICE']._serialized_start=3945 - _globals['_UNREGISTERSERVICE']._serialized_end=3997 - _globals['_UNREGISTERSERVICERESULT']._serialized_start=3999 - _globals['_UNREGISTERSERVICERESULT']._serialized_end=4053 - _globals['_FIREEVENT']._serialized_start=4055 - _globals['_FIREEVENT']._serialized_end=4171 - _globals['_STORELOAD']._serialized_start=4173 - _globals['_STORELOAD']._serialized_end=4197 - _globals['_STORELOADRESULT']._serialized_start=4199 - _globals['_STORELOADRESULT']._serialized_end=4269 - _globals['_STORESAVE']._serialized_start=4271 - _globals['_STORESAVE']._serialized_end=4334 - _globals['_STORESAVERESULT']._serialized_start=4336 - _globals['_STORESAVERESULT']._serialized_end=4365 - _globals['_STOREREMOVE']._serialized_start=4367 - _globals['_STOREREMOVE']._serialized_end=4393 - _globals['_STOREREMOVERESULT']._serialized_start=4395 - _globals['_STOREREMOVERESULT']._serialized_end=4426 + _globals['_INTEGRATIONSOURCE']._serialized_start=902 + _globals['_INTEGRATIONSOURCE']._serialized_end=1006 + _globals['_ENTRYSETUP']._serialized_start=1009 + _globals['_ENTRYSETUP']._serialized_end=1304 + _globals['_ENTRYSETUPRESULT']._serialized_start=1306 + _globals['_ENTRYSETUPRESULT']._serialized_end=1368 + _globals['_ENTRYUNLOAD']._serialized_start=1370 + _globals['_ENTRYUNLOAD']._serialized_end=1401 + _globals['_ENTRYUNLOADRESULT']._serialized_start=1403 + _globals['_ENTRYUNLOADRESULT']._serialized_end=1434 + _globals['_CALLSERVICE']._serialized_start=1437 + _globals['_CALLSERVICE']._serialized_end=1636 + _globals['_SERVICERESPONSE']._serialized_start=1638 + _globals['_SERVICERESPONSE']._serialized_end=1694 + _globals['_CALLSERVICERESULT']._serialized_start=1696 + _globals['_CALLSERVICERESULT']._serialized_end=1780 + _globals['_SHUTDOWN']._serialized_start=1782 + _globals['_SHUTDOWN']._serialized_end=1792 + _globals['_SHUTDOWNRESULT']._serialized_start=1794 + _globals['_SHUTDOWNRESULT']._serialized_end=1911 + _globals['_PING']._serialized_start=1913 + _globals['_PING']._serialized_end=1919 + _globals['_PINGRESULT']._serialized_start=1921 + _globals['_PINGRESULT']._serialized_end=1947 + _globals['_READY']._serialized_start=1949 + _globals['_READY']._serialized_end=1956 + _globals['_FLOWINIT']._serialized_start=1958 + _globals['_FLOWINIT']._serialized_end=2066 + _globals['_FLOWSTEP']._serialized_start=2068 + _globals['_FLOWSTEP']._serialized_end=2140 + _globals['_FLOWABORT']._serialized_start=2142 + _globals['_FLOWABORT']._serialized_end=2170 + _globals['_FLOWABORTRESULT']._serialized_start=2172 + _globals['_FLOWABORTRESULT']._serialized_end=2189 + _globals['_FLOWRESULT']._serialized_start=2192 + _globals['_FLOWRESULT']._serialized_end=2873 + _globals['_ENTITYINFO']._serialized_start=2876 + _globals['_ENTITYINFO']._serialized_end=3276 + _globals['_ENTITYINFO_DESCRIPTION']._serialized_start=3003 + _globals['_ENTITYINFO_DESCRIPTION']._serialized_end=3244 + _globals['_INITIALSTATE']._serialized_start=3279 + _globals['_INITIALSTATE']._serialized_end=3415 + _globals['_ENTITYDESCRIPTION']._serialized_start=3418 + _globals['_ENTITYDESCRIPTION']._serialized_end=3642 + _globals['_REGISTERENTITYRESULT']._serialized_start=3644 + _globals['_REGISTERENTITYRESULT']._serialized_end=3685 + _globals['_UNREGISTERENTITY']._serialized_start=3687 + _globals['_UNREGISTERENTITY']._serialized_end=3732 + _globals['_UNREGISTERENTITYRESULT']._serialized_start=3734 + _globals['_UNREGISTERENTITYRESULT']._serialized_end=3770 + _globals['_STATECHANGED']._serialized_start=3773 + _globals['_STATECHANGED']._serialized_end=3929 + _globals['_REGISTERSERVICE']._serialized_start=3931 + _globals['_REGISTERSERVICE']._serialized_end=4052 + _globals['_REGISTERSERVICERESULT']._serialized_start=4054 + _globals['_REGISTERSERVICERESULT']._serialized_end=4108 + _globals['_UNREGISTERSERVICE']._serialized_start=4110 + _globals['_UNREGISTERSERVICE']._serialized_end=4162 + _globals['_UNREGISTERSERVICERESULT']._serialized_start=4164 + _globals['_UNREGISTERSERVICERESULT']._serialized_end=4218 + _globals['_FIREEVENT']._serialized_start=4220 + _globals['_FIREEVENT']._serialized_end=4336 + _globals['_STORELOAD']._serialized_start=4338 + _globals['_STORELOAD']._serialized_end=4362 + _globals['_STORELOADRESULT']._serialized_start=4364 + _globals['_STORELOADRESULT']._serialized_end=4434 + _globals['_STORESAVE']._serialized_start=4436 + _globals['_STORESAVE']._serialized_end=4499 + _globals['_STORESAVERESULT']._serialized_start=4501 + _globals['_STORESAVERESULT']._serialized_end=4530 + _globals['_STOREREMOVE']._serialized_start=4532 + _globals['_STOREREMOVE']._serialized_end=4558 + _globals['_STOREREMOVERESULT']._serialized_start=4560 + _globals['_STOREREMOVERESULT']._serialized_end=4591 # @@protoc_insertion_point(module_scope) diff --git a/homeassistant/components/sandbox_v2/_proto/sandbox_v2_pb2.pyi b/homeassistant/components/sandbox_v2/_proto/sandbox_v2_pb2.pyi index 6c5923c8988..747b7222652 100644 --- a/homeassistant/components/sandbox_v2/_proto/sandbox_v2_pb2.pyi +++ b/homeassistant/components/sandbox_v2/_proto/sandbox_v2_pb2.pyi @@ -95,8 +95,24 @@ class DeviceInfo(_message.Message): translation_key: str def __init__(self, identifiers: _Optional[_Iterable[_Union[DevicePair, _Mapping]]] = ..., connections: _Optional[_Iterable[_Union[DevicePair, _Mapping]]] = ..., via_device: _Optional[_Union[DevicePair, _Mapping]] = ..., entry_type: _Optional[str] = ..., name: _Optional[str] = ..., manufacturer: _Optional[str] = ..., model: _Optional[str] = ..., model_id: _Optional[str] = ..., sw_version: _Optional[str] = ..., hw_version: _Optional[str] = ..., serial_number: _Optional[str] = ..., suggested_area: _Optional[str] = ..., configuration_url: _Optional[str] = ..., default_name: _Optional[str] = ..., default_manufacturer: _Optional[str] = ..., default_model: _Optional[str] = ..., translation_key: _Optional[str] = ...) -> None: ... +class IntegrationSource(_message.Message): + __slots__ = ("kind", "url", "ref", "tag", "domain", "subdir") + KIND_FIELD_NUMBER: _ClassVar[int] + URL_FIELD_NUMBER: _ClassVar[int] + REF_FIELD_NUMBER: _ClassVar[int] + TAG_FIELD_NUMBER: _ClassVar[int] + DOMAIN_FIELD_NUMBER: _ClassVar[int] + SUBDIR_FIELD_NUMBER: _ClassVar[int] + kind: str + url: str + ref: str + tag: str + domain: str + subdir: str + def __init__(self, kind: _Optional[str] = ..., url: _Optional[str] = ..., ref: _Optional[str] = ..., tag: _Optional[str] = ..., domain: _Optional[str] = ..., subdir: _Optional[str] = ...) -> None: ... + class EntrySetup(_message.Message): - __slots__ = ("entry_id", "domain", "title", "data", "options", "source", "unique_id", "version", "minor_version") + __slots__ = ("entry_id", "domain", "title", "data", "options", "source", "unique_id", "version", "minor_version", "integration_source") ENTRY_ID_FIELD_NUMBER: _ClassVar[int] DOMAIN_FIELD_NUMBER: _ClassVar[int] TITLE_FIELD_NUMBER: _ClassVar[int] @@ -106,6 +122,7 @@ class EntrySetup(_message.Message): UNIQUE_ID_FIELD_NUMBER: _ClassVar[int] VERSION_FIELD_NUMBER: _ClassVar[int] MINOR_VERSION_FIELD_NUMBER: _ClassVar[int] + INTEGRATION_SOURCE_FIELD_NUMBER: _ClassVar[int] entry_id: str domain: str title: str @@ -115,7 +132,8 @@ class EntrySetup(_message.Message): unique_id: str version: int minor_version: int - def __init__(self, entry_id: _Optional[str] = ..., domain: _Optional[str] = ..., title: _Optional[str] = ..., data: _Optional[_Union[_struct_pb2.Struct, _Mapping]] = ..., options: _Optional[_Union[_struct_pb2.Struct, _Mapping]] = ..., source: _Optional[str] = ..., unique_id: _Optional[str] = ..., version: _Optional[int] = ..., minor_version: _Optional[int] = ...) -> None: ... + integration_source: IntegrationSource + def __init__(self, entry_id: _Optional[str] = ..., domain: _Optional[str] = ..., title: _Optional[str] = ..., data: _Optional[_Union[_struct_pb2.Struct, _Mapping]] = ..., options: _Optional[_Union[_struct_pb2.Struct, _Mapping]] = ..., source: _Optional[str] = ..., unique_id: _Optional[str] = ..., version: _Optional[int] = ..., minor_version: _Optional[int] = ..., integration_source: _Optional[_Union[IntegrationSource, _Mapping]] = ...) -> None: ... class EntrySetupResult(_message.Message): __slots__ = ("ok", "reason") diff --git a/homeassistant/components/sandbox_v2/router.py b/homeassistant/components/sandbox_v2/router.py index 82295d8fad1..f039f600da3 100644 --- a/homeassistant/components/sandbox_v2/router.py +++ b/homeassistant/components/sandbox_v2/router.py @@ -35,6 +35,7 @@ from .manager import SandboxManager from .messages import dict_to_struct from .protocol import MSG_ENTRY_SETUP, MSG_ENTRY_UNLOAD from .proxy_flow import SandboxFlowProxy +from .sources import SandboxSourceError, async_resolve_integration_source if TYPE_CHECKING: from . import SandboxV2Data @@ -113,7 +114,19 @@ class SandboxFlowRouter: ) return False - payload = _entry_setup_payload(entry) + try: + payload = await _entry_setup_payload(self._hass, entry) + except SandboxSourceError as err: + _LOGGER.error( + "Cannot resolve integration source for entry %s (%s): %s", + entry.title, + entry.domain, + err, + ) + entry._async_set_state( # noqa: SLF001 + self._hass, ConfigEntryState.SETUP_ERROR, str(err) + ) + return False try: result = await channel.call(MSG_ENTRY_SETUP, payload) except ChannelClosedError: @@ -187,11 +200,16 @@ class SandboxFlowRouter: return classify(integration) -def _entry_setup_payload(entry: ConfigEntry) -> pb.EntrySetup: +async def _entry_setup_payload( + hass: HomeAssistant, entry: ConfigEntry +) -> pb.EntrySetup: """Build the typed ``EntrySetup`` message for ``sandbox_v2/entry_setup``. Surfaces the small subset of entry fields the integration's - ``async_setup_entry`` reads. + ``async_setup_entry`` reads, plus the ``integration_source`` descriptor + telling a stateless sandbox where to fetch the code (built-in → no-op; + custom → a git source pinned to an exact sha). May raise + :class:`SandboxSourceError` if a custom integration has no source resolver. """ msg = pb.EntrySetup( entry_id=entry.entry_id, @@ -205,6 +223,9 @@ def _entry_setup_payload(entry: ConfigEntry) -> pb.EntrySetup: ) if entry.unique_id is not None: msg.unique_id = entry.unique_id + msg.integration_source.CopyFrom( + await async_resolve_integration_source(hass, entry.domain) + ) return msg diff --git a/homeassistant/components/sandbox_v2/sources.py b/homeassistant/components/sandbox_v2/sources.py new file mode 100644 index 00000000000..d490451bf68 --- /dev/null +++ b/homeassistant/components/sandbox_v2/sources.py @@ -0,0 +1,152 @@ +"""Main-side integration-source resolution for stateless sandboxes. + +A sandbox holds no persistent state. The last stateful bit was the +integration *code*: built-ins ride the bundled ``homeassistant`` package, but +custom (HACS) integrations live under ``/custom_components`` on the +main install and are absent from a fresh sandbox. This module lets main tell +the sandbox *where to fetch the code* on ``entry_setup``; the sandbox fetches +it before setup (see ``hass_client.sources``). + +Core stays HACS-agnostic via a registered-resolver hook (decision (c), +2026-06-03): HACS — or any other distribution mechanism — registers a +resolver mapping a custom domain to a git source. Core ships only the +builtin-vs-git decision; with no resolver registered the default is +builtin-only, and a custom domain raises rather than silently falling back. + +Security / tag→sha contract: the ``ref`` that crosses the wire must be an +exact commit sha, never a moving tag. Core performs **no network I/O** here, +so the resolver is responsible for pinning the installed version to a sha and +returning it in ``ref`` (HACS already knows the sha of what the user +installed). ``tag`` is informational only (logs). If a resolver returns a git +source without a ``ref``, that is an error — main refuses to ship a sandbox a +moving reference. +""" + +from collections.abc import Callable +import logging +from typing import TypedDict + +from homeassistant.core import HomeAssistant, callback +from homeassistant.loader import async_get_integration +from homeassistant.util.hass_dict import HassKey + +from ._proto import sandbox_v2_pb2 as pb + +_LOGGER = logging.getLogger(__name__) + + +class IntegrationSourceDict(TypedDict, total=False): + """The dict shape a resolver returns for a custom (git) integration. + + ``kind`` is always ``"git"`` (built-ins never reach a resolver). ``url`` + and ``ref`` (an exact commit sha) are required; ``domain`` and ``subdir`` + default from the domain being resolved when omitted. + """ + + kind: str + url: str + ref: str + tag: str + domain: str + subdir: str + + +# A resolver maps a custom integration domain to its git source, or ``None`` +# if it does not know that domain. Called only for non-built-in integrations. +SandboxSourceResolver = Callable[[str], IntegrationSourceDict | None] + +DATA_SOURCE_RESOLVERS: HassKey[list[SandboxSourceResolver]] = HassKey( + "sandbox_v2_source_resolvers" +) + + +class SandboxSourceError(Exception): + """Raised when an integration's source cannot be resolved.""" + + +@callback +def async_register_sandbox_source_resolver( + hass: HomeAssistant, resolver: SandboxSourceResolver +) -> Callable[[], None]: + """Register a resolver mapping a custom domain to its git source. + + HACS (or any custom-integration distribution mechanism) calls this to + teach the sandbox where to fetch code from. Resolvers are consulted in + registration order; the first to return a non-``None`` source wins. The + resolver MUST pin ``ref`` to an exact commit sha (see module docstring). + + Returns a callback that unregisters the resolver. + """ + resolvers = hass.data.setdefault(DATA_SOURCE_RESOLVERS, []) + resolvers.append(resolver) + + @callback + def _unregister() -> None: + resolvers.remove(resolver) + + return _unregister + + +async def async_resolve_integration_source( + hass: HomeAssistant, domain: str +) -> pb.IntegrationSource: + """Resolve the source descriptor for ``domain``'s code. + + Built-in integrations short-circuit to ``{kind: "builtin"}`` (the bundled + ``homeassistant`` package provides them). For a custom integration the + registered resolvers are consulted in order; the first git source returned + is used. If no resolver knows the domain, raises :class:`SandboxSourceError` + — a custom integration with no source cannot run in a stateless sandbox, so + the failure is surfaced rather than masked. + """ + integration = await async_get_integration(hass, domain) + if integration.is_built_in: + return pb.IntegrationSource(kind="builtin") + + for resolver in hass.data.get(DATA_SOURCE_RESOLVERS, []): + source = resolver(domain) + if source is not None: + return _git_source_from_dict(domain, source) + + raise SandboxSourceError( + f"no sandbox source resolver knows custom integration {domain!r}; " + "a custom integration cannot run in a stateless sandbox without one" + ) + + +def _git_source_from_dict( + domain: str, source: IntegrationSourceDict +) -> pb.IntegrationSource: + """Build a typed git ``IntegrationSource`` from a resolver's dict. + + Validates the tag→sha pinning contract: ``url`` and an exact-sha ``ref`` + are required. ``domain`` and ``subdir`` default from ``domain``. + """ + url = source.get("url") + if not url: + raise SandboxSourceError( + f"resolver returned a git source for {domain!r} without a url" + ) + ref = source.get("ref") + if not ref: + raise SandboxSourceError( + f"resolver returned a git source for {domain!r} without a ref; " + "the resolver must pin the version to an exact commit sha" + ) + return pb.IntegrationSource( + kind="git", + url=url, + ref=ref, + tag=source.get("tag", ""), + domain=source.get("domain", domain), + subdir=source.get("subdir", f"custom_components/{domain}"), + ) + + +__all__ = [ + "IntegrationSourceDict", + "SandboxSourceError", + "SandboxSourceResolver", + "async_register_sandbox_source_resolver", + "async_resolve_integration_source", +] diff --git a/sandbox_v2/hass_client/hass_client/_proto/sandbox_v2_pb2.py b/sandbox_v2/hass_client/hass_client/_proto/sandbox_v2_pb2.py index 1ee795d6725..41eef0c37f8 100644 --- a/sandbox_v2/hass_client/hass_client/_proto/sandbox_v2_pb2.py +++ b/sandbox_v2/hass_client/hass_client/_proto/sandbox_v2_pb2.py @@ -25,7 +25,7 @@ _sym_db = _symbol_database.Default() from google.protobuf import struct_pb2 as google_dot_protobuf_dot_struct__pb2 -DESCRIPTOR = _descriptor_pool.Default().AddSerializedFile(b'\n\x10sandbox_v2.proto\x12\nsandbox_v2\x1a\x1cgoogle/protobuf/struct.proto\"f\n\x05\x46rame\x12\n\n\x02id\x18\x01 \x01(\r\x12\x0c\n\x04type\x18\x02 \x01(\t\x12\x11\n\x07request\x18\x03 \x01(\x0cH\x00\x12(\n\x08response\x18\x04 \x01(\x0b\x32\x14.sandbox_v2.ResponseH\x00\x42\x06\n\x04\x62ody\"H\n\x08Response\x12\n\n\x02ok\x18\x01 \x01(\x08\x12\x0e\n\x06result\x18\x02 \x01(\x0c\x12 \n\x05\x65rror\x18\x03 \x01(\x0b\x32\x11.sandbox_v2.Error\"c\n\x05\x45rror\x12\x0f\n\x07message\x18\x01 \x01(\t\x12\x0c\n\x04type\x18\x02 \x01(\t\x12)\n\x07invalid\x18\x03 \x03(\x0b\x32\x18.sandbox_v2.InvalidError\x12\x10\n\x08multiple\x18\x04 \x01(\x08\"-\n\x0cInvalidError\x12\x0f\n\x07message\x18\x01 \x01(\t\x12\x0c\n\x04path\x18\x02 \x03(\t\"(\n\nDevicePair\x12\x0b\n\x03key\x18\x01 \x01(\t\x12\r\n\x05value\x18\x02 \x01(\t\"\xd5\x03\n\nDeviceInfo\x12+\n\x0bidentifiers\x18\x01 \x03(\x0b\x32\x16.sandbox_v2.DevicePair\x12+\n\x0b\x63onnections\x18\x02 \x03(\x0b\x32\x16.sandbox_v2.DevicePair\x12/\n\nvia_device\x18\x03 \x01(\x0b\x32\x16.sandbox_v2.DevicePairH\x00\x88\x01\x01\x12\x12\n\nentry_type\x18\x04 \x01(\t\x12\x0c\n\x04name\x18\x05 \x01(\t\x12\x14\n\x0cmanufacturer\x18\x06 \x01(\t\x12\r\n\x05model\x18\x07 \x01(\t\x12\x10\n\x08model_id\x18\x08 \x01(\t\x12\x12\n\nsw_version\x18\t \x01(\t\x12\x12\n\nhw_version\x18\n \x01(\t\x12\x15\n\rserial_number\x18\x0b \x01(\t\x12\x16\n\x0esuggested_area\x18\x0c \x01(\t\x12\x19\n\x11\x63onfiguration_url\x18\r \x01(\t\x12\x14\n\x0c\x64\x65\x66\x61ult_name\x18\x0e \x01(\t\x12\x1c\n\x14\x64\x65\x66\x61ult_manufacturer\x18\x0f \x01(\t\x12\x15\n\rdefault_model\x18\x10 \x01(\t\x12\x17\n\x0ftranslation_key\x18\x11 \x01(\tB\r\n\x0b_via_device\"\xec\x01\n\nEntrySetup\x12\x10\n\x08\x65ntry_id\x18\x01 \x01(\t\x12\x0e\n\x06\x64omain\x18\x02 \x01(\t\x12\r\n\x05title\x18\x03 \x01(\t\x12%\n\x04\x64\x61ta\x18\x04 \x01(\x0b\x32\x17.google.protobuf.Struct\x12(\n\x07options\x18\x05 \x01(\x0b\x32\x17.google.protobuf.Struct\x12\x0e\n\x06source\x18\x06 \x01(\t\x12\x16\n\tunique_id\x18\x07 \x01(\tH\x00\x88\x01\x01\x12\x0f\n\x07version\x18\x08 \x01(\x05\x12\x15\n\rminor_version\x18\t \x01(\x05\x42\x0c\n\n_unique_id\">\n\x10\x45ntrySetupResult\x12\n\n\x02ok\x18\x01 \x01(\x08\x12\x13\n\x06reason\x18\x02 \x01(\tH\x00\x88\x01\x01\x42\t\n\x07_reason\"\x1f\n\x0b\x45ntryUnload\x12\x10\n\x08\x65ntry_id\x18\x01 \x01(\t\"\x1f\n\x11\x45ntryUnloadResult\x12\n\n\x02ok\x18\x01 \x01(\x08\"\xc7\x01\n\x0b\x43\x61llService\x12\x0e\n\x06\x64omain\x18\x01 \x01(\t\x12\x0f\n\x07service\x18\x02 \x01(\t\x12\'\n\x06target\x18\x03 \x01(\x0b\x32\x17.google.protobuf.Struct\x12-\n\x0cservice_data\x18\x04 \x01(\x0b\x32\x17.google.protobuf.Struct\x12\x17\n\ncontext_id\x18\x05 \x01(\tH\x00\x88\x01\x01\x12\x17\n\x0freturn_response\x18\x06 \x01(\x08\x42\r\n\x0b_context_id\"8\n\x0fServiceResponse\x12%\n\x04\x64\x61ta\x18\x01 \x01(\x0b\x32\x17.google.protobuf.Struct\"T\n\x11\x43\x61llServiceResult\x12\x32\n\x08response\x18\x01 \x01(\x0b\x32\x1b.sandbox_v2.ServiceResponseH\x00\x88\x01\x01\x42\x0b\n\t_response\"\n\n\x08Shutdown\"u\n\x0eShutdownResult\x12\n\n\x02ok\x18\x01 \x01(\x08\x12\x10\n\x08unloaded\x18\x02 \x01(\x05\x12\x33\n\rrestore_state\x18\x03 \x01(\x0b\x32\x17.google.protobuf.StructH\x00\x88\x01\x01\x42\x10\n\x0e_restore_state\"\x06\n\x04Ping\"\x1a\n\nPingResult\x12\x0c\n\x04pong\x18\x01 \x01(\t\"\x07\n\x05Ready\"l\n\x08\x46lowInit\x12\x0f\n\x07handler\x18\x01 \x01(\t\x12(\n\x07\x63ontext\x18\x02 \x01(\x0b\x32\x17.google.protobuf.Struct\x12%\n\x04\x64\x61ta\x18\x03 \x01(\x0b\x32\x17.google.protobuf.Struct\"H\n\x08\x46lowStep\x12\x0f\n\x07\x66low_id\x18\x01 \x01(\t\x12+\n\nuser_input\x18\x02 \x01(\x0b\x32\x17.google.protobuf.Struct\"\x1c\n\tFlowAbort\x12\x0f\n\x07\x66low_id\x18\x01 \x01(\t\"\x11\n\x0f\x46lowAbortResult\"\xa9\x05\n\nFlowResult\x12\x0c\n\x04type\x18\x01 \x01(\t\x12\x14\n\x07\x66low_id\x18\x02 \x01(\tH\x00\x88\x01\x01\x12\x14\n\x07handler\x18\x03 \x01(\tH\x01\x88\x01\x01\x12\x14\n\x07step_id\x18\x04 \x01(\tH\x02\x88\x01\x01\x12\x13\n\x06reason\x18\x05 \x01(\tH\x03\x88\x01\x01\x12\x12\n\x05title\x18\x06 \x01(\tH\x04\x88\x01\x01\x12\x18\n\x0b\x64\x65scription\x18\x07 \x01(\tH\x05\x88\x01\x01\x12\x16\n\tlast_step\x18\x08 \x01(\x08H\x06\x88\x01\x01\x12\x14\n\x07preview\x18\t \x01(\tH\x07\x88\x01\x01\x12\x14\n\x07version\x18\n \x01(\x05H\x08\x88\x01\x01\x12\x1a\n\rminor_version\x18\x0b \x01(\x05H\t\x88\x01\x01\x12%\n\x04\x64\x61ta\x18\x0c \x01(\x0b\x32\x17.google.protobuf.Struct\x12(\n\x07options\x18\r \x01(\x0b\x32\x17.google.protobuf.Struct\x12\'\n\x06\x65rrors\x18\x0e \x01(\x0b\x32\x17.google.protobuf.Struct\x12\x39\n\x18\x64\x65scription_placeholders\x18\x0f \x01(\x0b\x32\x17.google.protobuf.Struct\x12(\n\x07\x63ontext\x18\x10 \x01(\x0b\x32\x17.google.protobuf.Struct\x12/\n\x0b\x64\x61ta_schema\x18\x11 \x01(\x0b\x32\x1a.google.protobuf.ListValue\x12\x17\n\x0fhas_data_schema\x18\x12 \x01(\x08\x42\n\n\x08_flow_idB\n\n\x08_handlerB\n\n\x08_step_idB\t\n\x07_reasonB\x08\n\x06_titleB\x0e\n\x0c_descriptionB\x0c\n\n_last_stepB\n\n\x08_previewB\n\n\x08_versionB\x10\n\x0e_minor_version\"\x90\x03\n\nEntityInfo\x12<\n\x0b\x64\x65scription\x18\x01 \x01(\x0b\x32\".sandbox_v2.EntityInfo.DescriptionH\x00\x88\x01\x01\x12\x30\n\x0b\x64\x65vice_info\x18\x02 \x01(\x0b\x32\x16.sandbox_v2.DeviceInfoH\x01\x88\x01\x01\x1a\xf1\x01\n\x0b\x44\x65scription\x12\x11\n\x04name\x18\x01 \x01(\tH\x00\x88\x01\x01\x12\x11\n\x04icon\x18\x02 \x01(\tH\x01\x88\x01\x01\x12\x1c\n\x0f\x65ntity_category\x18\x03 \x01(\tH\x02\x88\x01\x01\x12\x19\n\x0c\x64\x65vice_class\x18\x04 \x01(\tH\x03\x88\x01\x01\x12\x1a\n\x12supported_features\x18\x05 \x01(\x05\x12\x1c\n\x0ftranslation_key\x18\x06 \x01(\tH\x04\x88\x01\x01\x42\x07\n\x05_nameB\x07\n\x05_iconB\x12\n\x10_entity_categoryB\x0f\n\r_device_classB\x12\n\x10_translation_keyB\x0e\n\x0c_descriptionB\x0e\n\x0c_device_info\"\x88\x01\n\x0cInitialState\x12\x12\n\x05state\x18\x01 \x01(\tH\x00\x88\x01\x01\x12-\n\x0c\x63\x61pabilities\x18\x02 \x01(\x0b\x32\x17.google.protobuf.Struct\x12+\n\nattributes\x18\x03 \x01(\x0b\x32\x17.google.protobuf.StructB\x08\n\x06_state\"\xe0\x01\n\x11\x45ntityDescription\x12\x10\n\x08\x65ntry_id\x18\x01 \x01(\t\x12\x0e\n\x06\x64omain\x18\x02 \x01(\t\x12\x19\n\x11sandbox_entity_id\x18\x03 \x01(\t\x12\x16\n\tunique_id\x18\x04 \x01(\tH\x00\x88\x01\x01\x12\x17\n\x0fhas_entity_name\x18\x05 \x01(\x08\x12$\n\x04info\x18\x06 \x01(\x0b\x32\x16.sandbox_v2.EntityInfo\x12)\n\x07initial\x18\x07 \x01(\x0b\x32\x18.sandbox_v2.InitialStateB\x0c\n\n_unique_id\")\n\x14RegisterEntityResult\x12\x11\n\tentity_id\x18\x01 \x01(\t\"-\n\x10UnregisterEntity\x12\x19\n\x11sandbox_entity_id\x18\x01 \x01(\t\"$\n\x16UnregisterEntityResult\x12\n\n\x02ok\x18\x01 \x01(\x08\"\x9c\x01\n\x0cStateChanged\x12\x19\n\x11sandbox_entity_id\x18\x01 \x01(\t\x12\x12\n\x05state\x18\x02 \x01(\tH\x00\x88\x01\x01\x12+\n\nattributes\x18\x03 \x01(\x0b\x32\x17.google.protobuf.Struct\x12\x17\n\ncontext_id\x18\x04 \x01(\tH\x01\x88\x01\x01\x42\x08\n\x06_stateB\r\n\x0b_context_id\"y\n\x0fRegisterService\x12\x0e\n\x06\x64omain\x18\x01 \x01(\t\x12\x0f\n\x07service\x18\x02 \x01(\t\x12\x19\n\x11supports_response\x18\x03 \x01(\t\x12*\n\x06schema\x18\x04 \x01(\x0b\x32\x1a.google.protobuf.ListValue\"6\n\x15RegisterServiceResult\x12\n\n\x02ok\x18\x01 \x01(\x08\x12\x11\n\tinstalled\x18\x02 \x01(\x08\"4\n\x11UnregisterService\x12\x0e\n\x06\x64omain\x18\x01 \x01(\t\x12\x0f\n\x07service\x18\x02 \x01(\t\"6\n\x17UnregisterServiceResult\x12\n\n\x02ok\x18\x01 \x01(\x08\x12\x0f\n\x07removed\x18\x02 \x01(\x08\"t\n\tFireEvent\x12\x12\n\nevent_type\x18\x01 \x01(\t\x12+\n\nevent_data\x18\x02 \x01(\x0b\x32\x17.google.protobuf.Struct\x12\x17\n\ncontext_id\x18\x03 \x01(\tH\x00\x88\x01\x01\x42\r\n\x0b_context_id\"\x18\n\tStoreLoad\x12\x0b\n\x03key\x18\x01 \x01(\t\"F\n\x0fStoreLoadResult\x12*\n\x04\x64\x61ta\x18\x01 \x01(\x0b\x32\x17.google.protobuf.StructH\x00\x88\x01\x01\x42\x07\n\x05_data\"?\n\tStoreSave\x12\x0b\n\x03key\x18\x01 \x01(\t\x12%\n\x04\x64\x61ta\x18\x02 \x01(\x0b\x32\x17.google.protobuf.Struct\"\x1d\n\x0fStoreSaveResult\x12\n\n\x02ok\x18\x01 \x01(\x08\"\x1a\n\x0bStoreRemove\x12\x0b\n\x03key\x18\x01 \x01(\t\"\x1f\n\x11StoreRemoveResult\x12\n\n\x02ok\x18\x01 \x01(\x08\x62\x06proto3') +DESCRIPTOR = _descriptor_pool.Default().AddSerializedFile(b'\n\x10sandbox_v2.proto\x12\nsandbox_v2\x1a\x1cgoogle/protobuf/struct.proto\"f\n\x05\x46rame\x12\n\n\x02id\x18\x01 \x01(\r\x12\x0c\n\x04type\x18\x02 \x01(\t\x12\x11\n\x07request\x18\x03 \x01(\x0cH\x00\x12(\n\x08response\x18\x04 \x01(\x0b\x32\x14.sandbox_v2.ResponseH\x00\x42\x06\n\x04\x62ody\"H\n\x08Response\x12\n\n\x02ok\x18\x01 \x01(\x08\x12\x0e\n\x06result\x18\x02 \x01(\x0c\x12 \n\x05\x65rror\x18\x03 \x01(\x0b\x32\x11.sandbox_v2.Error\"c\n\x05\x45rror\x12\x0f\n\x07message\x18\x01 \x01(\t\x12\x0c\n\x04type\x18\x02 \x01(\t\x12)\n\x07invalid\x18\x03 \x03(\x0b\x32\x18.sandbox_v2.InvalidError\x12\x10\n\x08multiple\x18\x04 \x01(\x08\"-\n\x0cInvalidError\x12\x0f\n\x07message\x18\x01 \x01(\t\x12\x0c\n\x04path\x18\x02 \x03(\t\"(\n\nDevicePair\x12\x0b\n\x03key\x18\x01 \x01(\t\x12\r\n\x05value\x18\x02 \x01(\t\"\xd5\x03\n\nDeviceInfo\x12+\n\x0bidentifiers\x18\x01 \x03(\x0b\x32\x16.sandbox_v2.DevicePair\x12+\n\x0b\x63onnections\x18\x02 \x03(\x0b\x32\x16.sandbox_v2.DevicePair\x12/\n\nvia_device\x18\x03 \x01(\x0b\x32\x16.sandbox_v2.DevicePairH\x00\x88\x01\x01\x12\x12\n\nentry_type\x18\x04 \x01(\t\x12\x0c\n\x04name\x18\x05 \x01(\t\x12\x14\n\x0cmanufacturer\x18\x06 \x01(\t\x12\r\n\x05model\x18\x07 \x01(\t\x12\x10\n\x08model_id\x18\x08 \x01(\t\x12\x12\n\nsw_version\x18\t \x01(\t\x12\x12\n\nhw_version\x18\n \x01(\t\x12\x15\n\rserial_number\x18\x0b \x01(\t\x12\x16\n\x0esuggested_area\x18\x0c \x01(\t\x12\x19\n\x11\x63onfiguration_url\x18\r \x01(\t\x12\x14\n\x0c\x64\x65\x66\x61ult_name\x18\x0e \x01(\t\x12\x1c\n\x14\x64\x65\x66\x61ult_manufacturer\x18\x0f \x01(\t\x12\x15\n\rdefault_model\x18\x10 \x01(\t\x12\x17\n\x0ftranslation_key\x18\x11 \x01(\tB\r\n\x0b_via_device\"h\n\x11IntegrationSource\x12\x0c\n\x04kind\x18\x01 \x01(\t\x12\x0b\n\x03url\x18\x02 \x01(\t\x12\x0b\n\x03ref\x18\x03 \x01(\t\x12\x0b\n\x03tag\x18\x04 \x01(\t\x12\x0e\n\x06\x64omain\x18\x05 \x01(\t\x12\x0e\n\x06subdir\x18\x06 \x01(\t\"\xa7\x02\n\nEntrySetup\x12\x10\n\x08\x65ntry_id\x18\x01 \x01(\t\x12\x0e\n\x06\x64omain\x18\x02 \x01(\t\x12\r\n\x05title\x18\x03 \x01(\t\x12%\n\x04\x64\x61ta\x18\x04 \x01(\x0b\x32\x17.google.protobuf.Struct\x12(\n\x07options\x18\x05 \x01(\x0b\x32\x17.google.protobuf.Struct\x12\x0e\n\x06source\x18\x06 \x01(\t\x12\x16\n\tunique_id\x18\x07 \x01(\tH\x00\x88\x01\x01\x12\x0f\n\x07version\x18\x08 \x01(\x05\x12\x15\n\rminor_version\x18\t \x01(\x05\x12\x39\n\x12integration_source\x18\n \x01(\x0b\x32\x1d.sandbox_v2.IntegrationSourceB\x0c\n\n_unique_id\">\n\x10\x45ntrySetupResult\x12\n\n\x02ok\x18\x01 \x01(\x08\x12\x13\n\x06reason\x18\x02 \x01(\tH\x00\x88\x01\x01\x42\t\n\x07_reason\"\x1f\n\x0b\x45ntryUnload\x12\x10\n\x08\x65ntry_id\x18\x01 \x01(\t\"\x1f\n\x11\x45ntryUnloadResult\x12\n\n\x02ok\x18\x01 \x01(\x08\"\xc7\x01\n\x0b\x43\x61llService\x12\x0e\n\x06\x64omain\x18\x01 \x01(\t\x12\x0f\n\x07service\x18\x02 \x01(\t\x12\'\n\x06target\x18\x03 \x01(\x0b\x32\x17.google.protobuf.Struct\x12-\n\x0cservice_data\x18\x04 \x01(\x0b\x32\x17.google.protobuf.Struct\x12\x17\n\ncontext_id\x18\x05 \x01(\tH\x00\x88\x01\x01\x12\x17\n\x0freturn_response\x18\x06 \x01(\x08\x42\r\n\x0b_context_id\"8\n\x0fServiceResponse\x12%\n\x04\x64\x61ta\x18\x01 \x01(\x0b\x32\x17.google.protobuf.Struct\"T\n\x11\x43\x61llServiceResult\x12\x32\n\x08response\x18\x01 \x01(\x0b\x32\x1b.sandbox_v2.ServiceResponseH\x00\x88\x01\x01\x42\x0b\n\t_response\"\n\n\x08Shutdown\"u\n\x0eShutdownResult\x12\n\n\x02ok\x18\x01 \x01(\x08\x12\x10\n\x08unloaded\x18\x02 \x01(\x05\x12\x33\n\rrestore_state\x18\x03 \x01(\x0b\x32\x17.google.protobuf.StructH\x00\x88\x01\x01\x42\x10\n\x0e_restore_state\"\x06\n\x04Ping\"\x1a\n\nPingResult\x12\x0c\n\x04pong\x18\x01 \x01(\t\"\x07\n\x05Ready\"l\n\x08\x46lowInit\x12\x0f\n\x07handler\x18\x01 \x01(\t\x12(\n\x07\x63ontext\x18\x02 \x01(\x0b\x32\x17.google.protobuf.Struct\x12%\n\x04\x64\x61ta\x18\x03 \x01(\x0b\x32\x17.google.protobuf.Struct\"H\n\x08\x46lowStep\x12\x0f\n\x07\x66low_id\x18\x01 \x01(\t\x12+\n\nuser_input\x18\x02 \x01(\x0b\x32\x17.google.protobuf.Struct\"\x1c\n\tFlowAbort\x12\x0f\n\x07\x66low_id\x18\x01 \x01(\t\"\x11\n\x0f\x46lowAbortResult\"\xa9\x05\n\nFlowResult\x12\x0c\n\x04type\x18\x01 \x01(\t\x12\x14\n\x07\x66low_id\x18\x02 \x01(\tH\x00\x88\x01\x01\x12\x14\n\x07handler\x18\x03 \x01(\tH\x01\x88\x01\x01\x12\x14\n\x07step_id\x18\x04 \x01(\tH\x02\x88\x01\x01\x12\x13\n\x06reason\x18\x05 \x01(\tH\x03\x88\x01\x01\x12\x12\n\x05title\x18\x06 \x01(\tH\x04\x88\x01\x01\x12\x18\n\x0b\x64\x65scription\x18\x07 \x01(\tH\x05\x88\x01\x01\x12\x16\n\tlast_step\x18\x08 \x01(\x08H\x06\x88\x01\x01\x12\x14\n\x07preview\x18\t \x01(\tH\x07\x88\x01\x01\x12\x14\n\x07version\x18\n \x01(\x05H\x08\x88\x01\x01\x12\x1a\n\rminor_version\x18\x0b \x01(\x05H\t\x88\x01\x01\x12%\n\x04\x64\x61ta\x18\x0c \x01(\x0b\x32\x17.google.protobuf.Struct\x12(\n\x07options\x18\r \x01(\x0b\x32\x17.google.protobuf.Struct\x12\'\n\x06\x65rrors\x18\x0e \x01(\x0b\x32\x17.google.protobuf.Struct\x12\x39\n\x18\x64\x65scription_placeholders\x18\x0f \x01(\x0b\x32\x17.google.protobuf.Struct\x12(\n\x07\x63ontext\x18\x10 \x01(\x0b\x32\x17.google.protobuf.Struct\x12/\n\x0b\x64\x61ta_schema\x18\x11 \x01(\x0b\x32\x1a.google.protobuf.ListValue\x12\x17\n\x0fhas_data_schema\x18\x12 \x01(\x08\x42\n\n\x08_flow_idB\n\n\x08_handlerB\n\n\x08_step_idB\t\n\x07_reasonB\x08\n\x06_titleB\x0e\n\x0c_descriptionB\x0c\n\n_last_stepB\n\n\x08_previewB\n\n\x08_versionB\x10\n\x0e_minor_version\"\x90\x03\n\nEntityInfo\x12<\n\x0b\x64\x65scription\x18\x01 \x01(\x0b\x32\".sandbox_v2.EntityInfo.DescriptionH\x00\x88\x01\x01\x12\x30\n\x0b\x64\x65vice_info\x18\x02 \x01(\x0b\x32\x16.sandbox_v2.DeviceInfoH\x01\x88\x01\x01\x1a\xf1\x01\n\x0b\x44\x65scription\x12\x11\n\x04name\x18\x01 \x01(\tH\x00\x88\x01\x01\x12\x11\n\x04icon\x18\x02 \x01(\tH\x01\x88\x01\x01\x12\x1c\n\x0f\x65ntity_category\x18\x03 \x01(\tH\x02\x88\x01\x01\x12\x19\n\x0c\x64\x65vice_class\x18\x04 \x01(\tH\x03\x88\x01\x01\x12\x1a\n\x12supported_features\x18\x05 \x01(\x05\x12\x1c\n\x0ftranslation_key\x18\x06 \x01(\tH\x04\x88\x01\x01\x42\x07\n\x05_nameB\x07\n\x05_iconB\x12\n\x10_entity_categoryB\x0f\n\r_device_classB\x12\n\x10_translation_keyB\x0e\n\x0c_descriptionB\x0e\n\x0c_device_info\"\x88\x01\n\x0cInitialState\x12\x12\n\x05state\x18\x01 \x01(\tH\x00\x88\x01\x01\x12-\n\x0c\x63\x61pabilities\x18\x02 \x01(\x0b\x32\x17.google.protobuf.Struct\x12+\n\nattributes\x18\x03 \x01(\x0b\x32\x17.google.protobuf.StructB\x08\n\x06_state\"\xe0\x01\n\x11\x45ntityDescription\x12\x10\n\x08\x65ntry_id\x18\x01 \x01(\t\x12\x0e\n\x06\x64omain\x18\x02 \x01(\t\x12\x19\n\x11sandbox_entity_id\x18\x03 \x01(\t\x12\x16\n\tunique_id\x18\x04 \x01(\tH\x00\x88\x01\x01\x12\x17\n\x0fhas_entity_name\x18\x05 \x01(\x08\x12$\n\x04info\x18\x06 \x01(\x0b\x32\x16.sandbox_v2.EntityInfo\x12)\n\x07initial\x18\x07 \x01(\x0b\x32\x18.sandbox_v2.InitialStateB\x0c\n\n_unique_id\")\n\x14RegisterEntityResult\x12\x11\n\tentity_id\x18\x01 \x01(\t\"-\n\x10UnregisterEntity\x12\x19\n\x11sandbox_entity_id\x18\x01 \x01(\t\"$\n\x16UnregisterEntityResult\x12\n\n\x02ok\x18\x01 \x01(\x08\"\x9c\x01\n\x0cStateChanged\x12\x19\n\x11sandbox_entity_id\x18\x01 \x01(\t\x12\x12\n\x05state\x18\x02 \x01(\tH\x00\x88\x01\x01\x12+\n\nattributes\x18\x03 \x01(\x0b\x32\x17.google.protobuf.Struct\x12\x17\n\ncontext_id\x18\x04 \x01(\tH\x01\x88\x01\x01\x42\x08\n\x06_stateB\r\n\x0b_context_id\"y\n\x0fRegisterService\x12\x0e\n\x06\x64omain\x18\x01 \x01(\t\x12\x0f\n\x07service\x18\x02 \x01(\t\x12\x19\n\x11supports_response\x18\x03 \x01(\t\x12*\n\x06schema\x18\x04 \x01(\x0b\x32\x1a.google.protobuf.ListValue\"6\n\x15RegisterServiceResult\x12\n\n\x02ok\x18\x01 \x01(\x08\x12\x11\n\tinstalled\x18\x02 \x01(\x08\"4\n\x11UnregisterService\x12\x0e\n\x06\x64omain\x18\x01 \x01(\t\x12\x0f\n\x07service\x18\x02 \x01(\t\"6\n\x17UnregisterServiceResult\x12\n\n\x02ok\x18\x01 \x01(\x08\x12\x0f\n\x07removed\x18\x02 \x01(\x08\"t\n\tFireEvent\x12\x12\n\nevent_type\x18\x01 \x01(\t\x12+\n\nevent_data\x18\x02 \x01(\x0b\x32\x17.google.protobuf.Struct\x12\x17\n\ncontext_id\x18\x03 \x01(\tH\x00\x88\x01\x01\x42\r\n\x0b_context_id\"\x18\n\tStoreLoad\x12\x0b\n\x03key\x18\x01 \x01(\t\"F\n\x0fStoreLoadResult\x12*\n\x04\x64\x61ta\x18\x01 \x01(\x0b\x32\x17.google.protobuf.StructH\x00\x88\x01\x01\x42\x07\n\x05_data\"?\n\tStoreSave\x12\x0b\n\x03key\x18\x01 \x01(\t\x12%\n\x04\x64\x61ta\x18\x02 \x01(\x0b\x32\x17.google.protobuf.Struct\"\x1d\n\x0fStoreSaveResult\x12\n\n\x02ok\x18\x01 \x01(\x08\"\x1a\n\x0bStoreRemove\x12\x0b\n\x03key\x18\x01 \x01(\t\"\x1f\n\x11StoreRemoveResult\x12\n\n\x02ok\x18\x01 \x01(\x08\x62\x06proto3') _globals = globals() _builder.BuildMessageAndEnumDescriptors(DESCRIPTOR, _globals) @@ -44,76 +44,78 @@ if not _descriptor._USE_C_DESCRIPTORS: _globals['_DEVICEPAIR']._serialized_end=428 _globals['_DEVICEINFO']._serialized_start=431 _globals['_DEVICEINFO']._serialized_end=900 - _globals['_ENTRYSETUP']._serialized_start=903 - _globals['_ENTRYSETUP']._serialized_end=1139 - _globals['_ENTRYSETUPRESULT']._serialized_start=1141 - _globals['_ENTRYSETUPRESULT']._serialized_end=1203 - _globals['_ENTRYUNLOAD']._serialized_start=1205 - _globals['_ENTRYUNLOAD']._serialized_end=1236 - _globals['_ENTRYUNLOADRESULT']._serialized_start=1238 - _globals['_ENTRYUNLOADRESULT']._serialized_end=1269 - _globals['_CALLSERVICE']._serialized_start=1272 - _globals['_CALLSERVICE']._serialized_end=1471 - _globals['_SERVICERESPONSE']._serialized_start=1473 - _globals['_SERVICERESPONSE']._serialized_end=1529 - _globals['_CALLSERVICERESULT']._serialized_start=1531 - _globals['_CALLSERVICERESULT']._serialized_end=1615 - _globals['_SHUTDOWN']._serialized_start=1617 - _globals['_SHUTDOWN']._serialized_end=1627 - _globals['_SHUTDOWNRESULT']._serialized_start=1629 - _globals['_SHUTDOWNRESULT']._serialized_end=1746 - _globals['_PING']._serialized_start=1748 - _globals['_PING']._serialized_end=1754 - _globals['_PINGRESULT']._serialized_start=1756 - _globals['_PINGRESULT']._serialized_end=1782 - _globals['_READY']._serialized_start=1784 - _globals['_READY']._serialized_end=1791 - _globals['_FLOWINIT']._serialized_start=1793 - _globals['_FLOWINIT']._serialized_end=1901 - _globals['_FLOWSTEP']._serialized_start=1903 - _globals['_FLOWSTEP']._serialized_end=1975 - _globals['_FLOWABORT']._serialized_start=1977 - _globals['_FLOWABORT']._serialized_end=2005 - _globals['_FLOWABORTRESULT']._serialized_start=2007 - _globals['_FLOWABORTRESULT']._serialized_end=2024 - _globals['_FLOWRESULT']._serialized_start=2027 - _globals['_FLOWRESULT']._serialized_end=2708 - _globals['_ENTITYINFO']._serialized_start=2711 - _globals['_ENTITYINFO']._serialized_end=3111 - _globals['_ENTITYINFO_DESCRIPTION']._serialized_start=2838 - _globals['_ENTITYINFO_DESCRIPTION']._serialized_end=3079 - _globals['_INITIALSTATE']._serialized_start=3114 - _globals['_INITIALSTATE']._serialized_end=3250 - _globals['_ENTITYDESCRIPTION']._serialized_start=3253 - _globals['_ENTITYDESCRIPTION']._serialized_end=3477 - _globals['_REGISTERENTITYRESULT']._serialized_start=3479 - _globals['_REGISTERENTITYRESULT']._serialized_end=3520 - _globals['_UNREGISTERENTITY']._serialized_start=3522 - _globals['_UNREGISTERENTITY']._serialized_end=3567 - _globals['_UNREGISTERENTITYRESULT']._serialized_start=3569 - _globals['_UNREGISTERENTITYRESULT']._serialized_end=3605 - _globals['_STATECHANGED']._serialized_start=3608 - _globals['_STATECHANGED']._serialized_end=3764 - _globals['_REGISTERSERVICE']._serialized_start=3766 - _globals['_REGISTERSERVICE']._serialized_end=3887 - _globals['_REGISTERSERVICERESULT']._serialized_start=3889 - _globals['_REGISTERSERVICERESULT']._serialized_end=3943 - _globals['_UNREGISTERSERVICE']._serialized_start=3945 - _globals['_UNREGISTERSERVICE']._serialized_end=3997 - _globals['_UNREGISTERSERVICERESULT']._serialized_start=3999 - _globals['_UNREGISTERSERVICERESULT']._serialized_end=4053 - _globals['_FIREEVENT']._serialized_start=4055 - _globals['_FIREEVENT']._serialized_end=4171 - _globals['_STORELOAD']._serialized_start=4173 - _globals['_STORELOAD']._serialized_end=4197 - _globals['_STORELOADRESULT']._serialized_start=4199 - _globals['_STORELOADRESULT']._serialized_end=4269 - _globals['_STORESAVE']._serialized_start=4271 - _globals['_STORESAVE']._serialized_end=4334 - _globals['_STORESAVERESULT']._serialized_start=4336 - _globals['_STORESAVERESULT']._serialized_end=4365 - _globals['_STOREREMOVE']._serialized_start=4367 - _globals['_STOREREMOVE']._serialized_end=4393 - _globals['_STOREREMOVERESULT']._serialized_start=4395 - _globals['_STOREREMOVERESULT']._serialized_end=4426 + _globals['_INTEGRATIONSOURCE']._serialized_start=902 + _globals['_INTEGRATIONSOURCE']._serialized_end=1006 + _globals['_ENTRYSETUP']._serialized_start=1009 + _globals['_ENTRYSETUP']._serialized_end=1304 + _globals['_ENTRYSETUPRESULT']._serialized_start=1306 + _globals['_ENTRYSETUPRESULT']._serialized_end=1368 + _globals['_ENTRYUNLOAD']._serialized_start=1370 + _globals['_ENTRYUNLOAD']._serialized_end=1401 + _globals['_ENTRYUNLOADRESULT']._serialized_start=1403 + _globals['_ENTRYUNLOADRESULT']._serialized_end=1434 + _globals['_CALLSERVICE']._serialized_start=1437 + _globals['_CALLSERVICE']._serialized_end=1636 + _globals['_SERVICERESPONSE']._serialized_start=1638 + _globals['_SERVICERESPONSE']._serialized_end=1694 + _globals['_CALLSERVICERESULT']._serialized_start=1696 + _globals['_CALLSERVICERESULT']._serialized_end=1780 + _globals['_SHUTDOWN']._serialized_start=1782 + _globals['_SHUTDOWN']._serialized_end=1792 + _globals['_SHUTDOWNRESULT']._serialized_start=1794 + _globals['_SHUTDOWNRESULT']._serialized_end=1911 + _globals['_PING']._serialized_start=1913 + _globals['_PING']._serialized_end=1919 + _globals['_PINGRESULT']._serialized_start=1921 + _globals['_PINGRESULT']._serialized_end=1947 + _globals['_READY']._serialized_start=1949 + _globals['_READY']._serialized_end=1956 + _globals['_FLOWINIT']._serialized_start=1958 + _globals['_FLOWINIT']._serialized_end=2066 + _globals['_FLOWSTEP']._serialized_start=2068 + _globals['_FLOWSTEP']._serialized_end=2140 + _globals['_FLOWABORT']._serialized_start=2142 + _globals['_FLOWABORT']._serialized_end=2170 + _globals['_FLOWABORTRESULT']._serialized_start=2172 + _globals['_FLOWABORTRESULT']._serialized_end=2189 + _globals['_FLOWRESULT']._serialized_start=2192 + _globals['_FLOWRESULT']._serialized_end=2873 + _globals['_ENTITYINFO']._serialized_start=2876 + _globals['_ENTITYINFO']._serialized_end=3276 + _globals['_ENTITYINFO_DESCRIPTION']._serialized_start=3003 + _globals['_ENTITYINFO_DESCRIPTION']._serialized_end=3244 + _globals['_INITIALSTATE']._serialized_start=3279 + _globals['_INITIALSTATE']._serialized_end=3415 + _globals['_ENTITYDESCRIPTION']._serialized_start=3418 + _globals['_ENTITYDESCRIPTION']._serialized_end=3642 + _globals['_REGISTERENTITYRESULT']._serialized_start=3644 + _globals['_REGISTERENTITYRESULT']._serialized_end=3685 + _globals['_UNREGISTERENTITY']._serialized_start=3687 + _globals['_UNREGISTERENTITY']._serialized_end=3732 + _globals['_UNREGISTERENTITYRESULT']._serialized_start=3734 + _globals['_UNREGISTERENTITYRESULT']._serialized_end=3770 + _globals['_STATECHANGED']._serialized_start=3773 + _globals['_STATECHANGED']._serialized_end=3929 + _globals['_REGISTERSERVICE']._serialized_start=3931 + _globals['_REGISTERSERVICE']._serialized_end=4052 + _globals['_REGISTERSERVICERESULT']._serialized_start=4054 + _globals['_REGISTERSERVICERESULT']._serialized_end=4108 + _globals['_UNREGISTERSERVICE']._serialized_start=4110 + _globals['_UNREGISTERSERVICE']._serialized_end=4162 + _globals['_UNREGISTERSERVICERESULT']._serialized_start=4164 + _globals['_UNREGISTERSERVICERESULT']._serialized_end=4218 + _globals['_FIREEVENT']._serialized_start=4220 + _globals['_FIREEVENT']._serialized_end=4336 + _globals['_STORELOAD']._serialized_start=4338 + _globals['_STORELOAD']._serialized_end=4362 + _globals['_STORELOADRESULT']._serialized_start=4364 + _globals['_STORELOADRESULT']._serialized_end=4434 + _globals['_STORESAVE']._serialized_start=4436 + _globals['_STORESAVE']._serialized_end=4499 + _globals['_STORESAVERESULT']._serialized_start=4501 + _globals['_STORESAVERESULT']._serialized_end=4530 + _globals['_STOREREMOVE']._serialized_start=4532 + _globals['_STOREREMOVE']._serialized_end=4558 + _globals['_STOREREMOVERESULT']._serialized_start=4560 + _globals['_STOREREMOVERESULT']._serialized_end=4591 # @@protoc_insertion_point(module_scope) diff --git a/sandbox_v2/hass_client/hass_client/_proto/sandbox_v2_pb2.pyi b/sandbox_v2/hass_client/hass_client/_proto/sandbox_v2_pb2.pyi index 6c5923c8988..747b7222652 100644 --- a/sandbox_v2/hass_client/hass_client/_proto/sandbox_v2_pb2.pyi +++ b/sandbox_v2/hass_client/hass_client/_proto/sandbox_v2_pb2.pyi @@ -95,8 +95,24 @@ class DeviceInfo(_message.Message): translation_key: str def __init__(self, identifiers: _Optional[_Iterable[_Union[DevicePair, _Mapping]]] = ..., connections: _Optional[_Iterable[_Union[DevicePair, _Mapping]]] = ..., via_device: _Optional[_Union[DevicePair, _Mapping]] = ..., entry_type: _Optional[str] = ..., name: _Optional[str] = ..., manufacturer: _Optional[str] = ..., model: _Optional[str] = ..., model_id: _Optional[str] = ..., sw_version: _Optional[str] = ..., hw_version: _Optional[str] = ..., serial_number: _Optional[str] = ..., suggested_area: _Optional[str] = ..., configuration_url: _Optional[str] = ..., default_name: _Optional[str] = ..., default_manufacturer: _Optional[str] = ..., default_model: _Optional[str] = ..., translation_key: _Optional[str] = ...) -> None: ... +class IntegrationSource(_message.Message): + __slots__ = ("kind", "url", "ref", "tag", "domain", "subdir") + KIND_FIELD_NUMBER: _ClassVar[int] + URL_FIELD_NUMBER: _ClassVar[int] + REF_FIELD_NUMBER: _ClassVar[int] + TAG_FIELD_NUMBER: _ClassVar[int] + DOMAIN_FIELD_NUMBER: _ClassVar[int] + SUBDIR_FIELD_NUMBER: _ClassVar[int] + kind: str + url: str + ref: str + tag: str + domain: str + subdir: str + def __init__(self, kind: _Optional[str] = ..., url: _Optional[str] = ..., ref: _Optional[str] = ..., tag: _Optional[str] = ..., domain: _Optional[str] = ..., subdir: _Optional[str] = ...) -> None: ... + class EntrySetup(_message.Message): - __slots__ = ("entry_id", "domain", "title", "data", "options", "source", "unique_id", "version", "minor_version") + __slots__ = ("entry_id", "domain", "title", "data", "options", "source", "unique_id", "version", "minor_version", "integration_source") ENTRY_ID_FIELD_NUMBER: _ClassVar[int] DOMAIN_FIELD_NUMBER: _ClassVar[int] TITLE_FIELD_NUMBER: _ClassVar[int] @@ -106,6 +122,7 @@ class EntrySetup(_message.Message): UNIQUE_ID_FIELD_NUMBER: _ClassVar[int] VERSION_FIELD_NUMBER: _ClassVar[int] MINOR_VERSION_FIELD_NUMBER: _ClassVar[int] + INTEGRATION_SOURCE_FIELD_NUMBER: _ClassVar[int] entry_id: str domain: str title: str @@ -115,7 +132,8 @@ class EntrySetup(_message.Message): unique_id: str version: int minor_version: int - def __init__(self, entry_id: _Optional[str] = ..., domain: _Optional[str] = ..., title: _Optional[str] = ..., data: _Optional[_Union[_struct_pb2.Struct, _Mapping]] = ..., options: _Optional[_Union[_struct_pb2.Struct, _Mapping]] = ..., source: _Optional[str] = ..., unique_id: _Optional[str] = ..., version: _Optional[int] = ..., minor_version: _Optional[int] = ...) -> None: ... + integration_source: IntegrationSource + def __init__(self, entry_id: _Optional[str] = ..., domain: _Optional[str] = ..., title: _Optional[str] = ..., data: _Optional[_Union[_struct_pb2.Struct, _Mapping]] = ..., options: _Optional[_Union[_struct_pb2.Struct, _Mapping]] = ..., source: _Optional[str] = ..., unique_id: _Optional[str] = ..., version: _Optional[int] = ..., minor_version: _Optional[int] = ..., integration_source: _Optional[_Union[IntegrationSource, _Mapping]] = ...) -> None: ... class EntrySetupResult(_message.Message): __slots__ = ("ok", "reason") diff --git a/sandbox_v2/hass_client/hass_client/entry_runner.py b/sandbox_v2/hass_client/hass_client/entry_runner.py index a4549c25216..430673bb04e 100644 --- a/sandbox_v2/hass_client/hass_client/entry_runner.py +++ b/sandbox_v2/hass_client/hass_client/entry_runner.py @@ -19,6 +19,7 @@ from .approved_domains import ApprovedDomains from .channel import Channel from .messages import dict_to_struct, struct_to_dict from .protocol import MSG_CALL_SERVICE, MSG_ENTRY_SETUP, MSG_ENTRY_UNLOAD +from .sources import FetchPrimitive, SandboxSourceError, async_ensure_integration_source _LOGGER = logging.getLogger(__name__) @@ -27,15 +28,22 @@ class EntryRunner: """Load integrations on demand and run config entries inside the sandbox.""" def __init__( - self, hass: HomeAssistant, approved: ApprovedDomains | None = None + self, + hass: HomeAssistant, + approved: ApprovedDomains | None = None, + *, + fetch: FetchPrimitive | None = None, ) -> None: """Initialise with the sandbox-private HA instance. ``approved`` is shared with the service + event mirrors so an entry's domain becomes approved as soon as setup completes. + ``fetch`` overrides the integration-source download primitive (tests + inject a local stub); ``None`` uses the real codeload tarball fetch. """ self.hass = hass self.approved = approved if approved is not None else ApprovedDomains() + self._fetch = fetch def register(self, channel: Channel) -> None: """Wire the ``sandbox_v2/entry_*`` + ``call_service`` handlers.""" @@ -50,6 +58,24 @@ class EntryRunner: except (KeyError, TypeError) as err: return pb.EntrySetupResult(ok=False, reason=f"bad payload: {err}") + # Fetch the integration code before setup so a stateless sandbox can + # load custom (HACS) integrations whose code isn't bundled. Built-in + # sources are a no-op. + try: + await async_ensure_integration_source( + self.hass.config.config_dir, + msg.integration_source, + fetch=self._fetch, + ) + except SandboxSourceError as err: + _LOGGER.error( + "sandbox entry_setup: source fetch failed for %s (%s): %s", + entry.title, + entry.domain, + err, + ) + return pb.EntrySetupResult(ok=False, reason=f"source fetch failed: {err}") + config_entries = self.hass.config_entries if config_entries.async_get_entry(entry.entry_id) is not None: return pb.EntrySetupResult(ok=False, reason="entry already loaded") diff --git a/sandbox_v2/hass_client/hass_client/sources.py b/sandbox_v2/hass_client/hass_client/sources.py new file mode 100644 index 00000000000..3990450d08f --- /dev/null +++ b/sandbox_v2/hass_client/hass_client/sources.py @@ -0,0 +1,193 @@ +"""Sandbox-side integration-source fetching — fetch custom code at startup. + +A stateless sandbox starts with only the bundled ``homeassistant`` package on +disk. Built-in integrations are already present (no-op); custom (HACS) +integrations carry a git :class:`~._proto.sandbox_v2_pb2.IntegrationSource` +descriptor on ``entry_setup`` that the sandbox fetches into +``/custom_components/`` *before* ``async_setup`` runs (see +:meth:`hass_client.entry_runner.EntryRunner._handle_entry_setup`). + +The fetch uses GitHub's codeload tarball for the exact commit sha (no ``git`` +binary dependency, matching HACS). A process-lifetime cache keyed by +``(url, ref)`` means multiple entries sourced from the same repo download once. + +The download primitive is injectable so tests substitute a local fixture for +the real network fetch — no test ever hits GitHub. +""" + +import asyncio +from collections.abc import Awaitable, Callable +import io +import logging +from pathlib import Path +import tarfile + +from ._proto import sandbox_v2_pb2 as pb + +_LOGGER = logging.getLogger(__name__) + +# url, ref -> downloaded tarball bytes. Process-lifetime only (honours +# "stateless": nothing survives a process restart). Guarded by _CACHE_LOCK so +# concurrent entries from the same repo download exactly once. +_TARBALL_CACHE: dict[tuple[str, str], bytes] = {} +_CACHE_LOCK = asyncio.Lock() + +# (repo url, exact sha) -> tarball bytes. +FetchPrimitive = Callable[[str, str], Awaitable[bytes]] + + +class SandboxSourceError(Exception): + """Raised when a custom integration's source cannot be fetched.""" + + +async def async_ensure_integration_source( + config_dir: str, + source: pb.IntegrationSource, + *, + fetch: FetchPrimitive | None = None, +) -> None: + """Ensure ``source``'s integration code is present under ``config_dir``. + + * ``builtin`` (or an unset source) → no-op; the bundled ``homeassistant`` + package provides built-ins. + * ``git`` → if ``/custom_components/`` is not already + present, download the tarball for the exact ``ref`` and extract the + repo's ``subdir`` into it. + + ``fetch`` is the download primitive ``(url, ref) -> tarball bytes``; + defaults to the real codeload download. Tests pass a local stub. + """ + kind = source.kind or "builtin" + if kind == "builtin": + return + if kind != "git": + raise SandboxSourceError(f"unknown integration source kind {kind!r}") + + domain = source.domain + if not domain: + raise SandboxSourceError("git integration source is missing a domain") + if not source.url or not source.ref: + raise SandboxSourceError( + f"git integration source for {domain!r} is missing url/ref" + ) + + dest = Path(config_dir) / "custom_components" / domain + manifest = dest / "manifest.json" + if manifest.exists(): + # Already fetched into this config dir (another entry of the same + # domain, or a prior call) — nothing to do. + return + + subdir = source.subdir or f"custom_components/{domain}" + fetcher = fetch if fetch is not None else _default_fetch + key = (source.url, source.ref) + + async with _CACHE_LOCK: + tarball = _TARBALL_CACHE.get(key) + if tarball is None: + _LOGGER.info( + "sandbox: fetching %s from %s@%s (%s)", + domain, + source.url, + source.ref, + source.tag or "no tag", + ) + tarball = await fetcher(source.url, source.ref) + _TARBALL_CACHE[key] = tarball + + await asyncio.get_running_loop().run_in_executor( + None, _extract_subdir, tarball, subdir, dest + ) + + if not manifest.exists(): + raise SandboxSourceError( + f"fetched source for {domain!r} has no manifest.json at " + f"{subdir!r} (ref {source.ref})" + ) + + +def _extract_subdir(tarball: bytes, subdir: str, dest: Path) -> None: + """Extract ``//`` from a gzipped tarball into ``dest``. + + GitHub's codeload tarball wraps everything in a single top-level + ``-/`` directory; the integration lives at ``/``. + Members outside the subdir are ignored; any member resolving outside + ``dest`` (path traversal) is rejected. + """ + subdir = subdir.strip("/") + dest_root = dest.resolve() + with tarfile.open(fileobj=io.BytesIO(tarball), mode="r:gz") as tar: + members = tar.getmembers() + if not members: + raise SandboxSourceError("fetched tarball is empty") + top = members[0].name.split("/", 1)[0] + prefix = f"{top}/{subdir}/" + extracted = 0 + for member in members: + if not member.name.startswith(prefix): + continue + rel = member.name[len(prefix) :] + if not rel: + continue + target = (dest / rel).resolve() + if not target.is_relative_to(dest_root): + raise SandboxSourceError( + f"refusing path-traversal member {member.name!r}" + ) + if member.isdir(): + target.mkdir(parents=True, exist_ok=True) + continue + if not member.isfile(): + # Skip symlinks / devices / etc. — integration trees are + # plain files; anything else is suspect. + continue + target.parent.mkdir(parents=True, exist_ok=True) + source = tar.extractfile(member) + if source is None: + continue + with source, target.open("wb") as handle: + handle.write(source.read()) + extracted += 1 + if extracted == 0: + raise SandboxSourceError( + f"fetched tarball had no files under {prefix!r}" + ) + + +def _codeload_url(url: str, ref: str) -> str: + """Build the GitHub codeload tarball URL for ``url`` at ``ref``. + + ``https://github.com/owner/repo`` → ``https://codeload.github.com/owner/ + repo/tar.gz/``. + """ + trimmed = url.rstrip("/").removesuffix(".git") + parts = trimmed.split("/") + if len(parts) < 2: + raise SandboxSourceError(f"cannot derive owner/repo from url {url!r}") + owner, repo = parts[-2], parts[-1] + return f"https://codeload.github.com/{owner}/{repo}/tar.gz/{ref}" + + +async def _default_fetch(url: str, ref: str) -> bytes: + """Download the codeload tarball for ``url`` at the exact ``ref`` (sha). + + The real network path. A one-shot transient session is fine here: this + runs once per ``(url, ref)`` at sandbox startup. Imported lazily so the + module (and tests using a stub) never require ``aiohttp``. + """ + import aiohttp # noqa: PLC0415 — keep aiohttp optional for the stubbed path + + tar_url = _codeload_url(url, ref) + async with ( + aiohttp.ClientSession() as session, + session.get(tar_url) as response, + ): + response.raise_for_status() + return await response.read() + + +__all__ = [ + "FetchPrimitive", + "SandboxSourceError", + "async_ensure_integration_source", +] diff --git a/sandbox_v2/hass_client/tests/test_sources.py b/sandbox_v2/hass_client/tests/test_sources.py new file mode 100644 index 00000000000..53c5793ac1b --- /dev/null +++ b/sandbox_v2/hass_client/tests/test_sources.py @@ -0,0 +1,210 @@ +"""Tests for the sandbox-side integration-source fetch (``hass_client.sources``). + +All fetches use a local in-memory tarball fixture — no test hits the network. +""" + +from collections.abc import Iterator +import io +from pathlib import Path +import tarfile + +from hass_client import sources as sources_module +from hass_client._proto import sandbox_v2_pb2 as pb +from hass_client.sources import SandboxSourceError, async_ensure_integration_source +import pytest + + +@pytest.fixture(autouse=True) +def _clear_tarball_cache() -> Iterator[None]: + """Reset the process-lifetime tarball cache between tests for isolation.""" + sources_module._TARBALL_CACHE.clear() # noqa: SLF001 + yield + sources_module._TARBALL_CACHE.clear() # noqa: SLF001 + + +def _make_tarball( + *, top: str, files: dict[str, str] +) -> bytes: + """Build a gzipped tarball mimicking GitHub's codeload layout. + + ``files`` maps a path relative to ``top`` to its contents. + """ + buffer = io.BytesIO() + with tarfile.open(fileobj=buffer, mode="w:gz") as tar: + for rel, content in files.items(): + data = content.encode() + info = tarfile.TarInfo(name=f"{top}/{rel}") + info.size = len(data) + tar.addfile(info, io.BytesIO(data)) + return buffer.getvalue() + + +def _git_source(domain: str = "my_custom") -> pb.IntegrationSource: + return pb.IntegrationSource( + kind="git", + url="https://github.com/owner/my_custom", + ref="a" * 40, + tag="v1.0.0", + domain=domain, + subdir=f"custom_components/{domain}", + ) + + +async def test_builtin_is_a_noop(tmp_path: Path) -> None: + """A builtin source fetches nothing and creates no files.""" + calls = 0 + + async def _fetch(url: str, ref: str) -> bytes: + nonlocal calls + calls += 1 + return b"" + + await async_ensure_integration_source( + str(tmp_path), pb.IntegrationSource(kind="builtin"), fetch=_fetch + ) + + assert calls == 0 + assert not (tmp_path / "custom_components").exists() + + +async def test_unset_source_is_a_noop(tmp_path: Path) -> None: + """An unset (default) source is treated as builtin — no fetch.""" + calls = 0 + + async def _fetch(url: str, ref: str) -> bytes: + nonlocal calls + calls += 1 + return b"" + + await async_ensure_integration_source( + str(tmp_path), pb.IntegrationSource(), fetch=_fetch + ) + + assert calls == 0 + + +async def test_git_source_extracts_into_config_dir(tmp_path: Path) -> None: + """A git source extracts the repo subdir into custom_components/.""" + tarball = _make_tarball( + top="my_custom-aaaa", + files={ + "custom_components/my_custom/manifest.json": '{"domain": "my_custom"}', + "custom_components/my_custom/__init__.py": "DOMAIN = 'my_custom'", + "README.md": "ignored — outside the subdir", + }, + ) + + async def _fetch(url: str, ref: str) -> bytes: + return tarball + + await async_ensure_integration_source( + str(tmp_path), _git_source(), fetch=_fetch + ) + + dest = tmp_path / "custom_components" / "my_custom" + assert (dest / "manifest.json").read_text() == '{"domain": "my_custom"}' + assert (dest / "__init__.py").read_text() == "DOMAIN = 'my_custom'" + # Files outside the subdir are not extracted. + assert not (tmp_path / "README.md").exists() + + +async def test_second_call_same_ref_hits_cache(tmp_path: Path) -> None: + """Two entries from the same (url, ref) download once.""" + tarball = _make_tarball( + top="my_custom-aaaa", + files={ + "custom_components/foo/manifest.json": "{}", + "custom_components/bar/manifest.json": "{}", + }, + ) + calls = 0 + + async def _fetch(url: str, ref: str) -> bytes: + nonlocal calls + calls += 1 + return tarball + + source_foo = pb.IntegrationSource( + kind="git", + url="https://github.com/owner/repo", + ref="z" * 40, + domain="foo", + subdir="custom_components/foo", + ) + source_bar = pb.IntegrationSource( + kind="git", + url="https://github.com/owner/repo", + ref="z" * 40, + domain="bar", + subdir="custom_components/bar", + ) + + await async_ensure_integration_source(str(tmp_path), source_foo, fetch=_fetch) + await async_ensure_integration_source(str(tmp_path), source_bar, fetch=_fetch) + + assert calls == 1 + assert (tmp_path / "custom_components" / "foo" / "manifest.json").exists() + assert (tmp_path / "custom_components" / "bar" / "manifest.json").exists() + + +async def test_already_present_skips_fetch(tmp_path: Path) -> None: + """An existing custom_components//manifest.json skips the fetch.""" + dest = tmp_path / "custom_components" / "my_custom" + dest.mkdir(parents=True) + (dest / "manifest.json").write_text("{}") + calls = 0 + + async def _fetch(url: str, ref: str) -> bytes: + nonlocal calls + calls += 1 + return b"" + + await async_ensure_integration_source( + str(tmp_path), _git_source(), fetch=_fetch + ) + + assert calls == 0 + + +async def test_missing_manifest_raises(tmp_path: Path) -> None: + """A fetched tree without manifest.json is rejected.""" + tarball = _make_tarball( + top="my_custom-aaaa", + files={"custom_components/my_custom/__init__.py": "x = 1"}, + ) + + async def _fetch(url: str, ref: str) -> bytes: + return tarball + + with pytest.raises(SandboxSourceError, match="manifest.json"): + await async_ensure_integration_source( + str(tmp_path), _git_source(), fetch=_fetch + ) + + +async def test_unknown_kind_raises(tmp_path: Path) -> None: + """An unrecognised source kind is an error.""" + + async def _fetch(url: str, ref: str) -> bytes: + return b"" + + with pytest.raises(SandboxSourceError, match="unknown integration source kind"): + await async_ensure_integration_source( + str(tmp_path), pb.IntegrationSource(kind="svn"), fetch=_fetch + ) + + +async def test_empty_subdir_match_raises(tmp_path: Path) -> None: + """A tarball with no files under the subdir is rejected.""" + tarball = _make_tarball( + top="my_custom-aaaa", + files={"some/other/path.py": "x = 1"}, + ) + + async def _fetch(url: str, ref: str) -> bytes: + return tarball + + with pytest.raises(SandboxSourceError): + await async_ensure_integration_source( + str(tmp_path), _git_source(), fetch=_fetch + ) diff --git a/sandbox_v2/proto/sandbox_v2.proto b/sandbox_v2/proto/sandbox_v2.proto index f2f0341f412..eabe9ec9efa 100644 --- a/sandbox_v2/proto/sandbox_v2.proto +++ b/sandbox_v2/proto/sandbox_v2.proto @@ -91,6 +91,21 @@ message DeviceInfo { // --- entry_setup / entry_unload (main -> sandbox) ------------------------- +// Where the sandbox should source the integration's code from. Makes the +// sandbox stateless: built-ins ride the bundled `homeassistant` package +// (no-op), custom (HACS) integrations carry a git source the sandbox fetches +// at startup. Main pins the source to an exact commit `ref` (sha) before it +// crosses the wire — never a moving tag — see `sources.py`. +message IntegrationSource { + string kind = 1; // "builtin" | "git" + // git-only fields (unset for builtin): + string url = 2; // repository url, e.g. https://github.com/owner/repo + string ref = 3; // exact commit sha (main pins tag -> sha) + string tag = 4; // human-readable version, for logs only + string domain = 5; // the custom integration's domain + string subdir = 6; // path within the repo, e.g. custom_components/ +} + message EntrySetup { string entry_id = 1; string domain = 2; @@ -101,6 +116,7 @@ message EntrySetup { optional string unique_id = 7; int32 version = 8; int32 minor_version = 9; + IntegrationSource integration_source = 10; } message EntrySetupResult { diff --git a/tests/components/sandbox_v2/test_router.py b/tests/components/sandbox_v2/test_router.py index f6718da15fe..bcd8596b894 100644 --- a/tests/components/sandbox_v2/test_router.py +++ b/tests/components/sandbox_v2/test_router.py @@ -8,7 +8,13 @@ from homeassistant.components.sandbox_v2._proto import sandbox_v2_pb2 as pb from homeassistant.components.sandbox_v2.manager import SandboxManager from homeassistant.components.sandbox_v2.messages import struct_to_dict from homeassistant.components.sandbox_v2.proxy_flow import SandboxFlowProxy -from homeassistant.components.sandbox_v2.router import SandboxFlowRouter +from homeassistant.components.sandbox_v2.router import ( + SandboxFlowRouter, + _entry_setup_payload, +) +from homeassistant.components.sandbox_v2.sources import ( + async_register_sandbox_source_resolver, +) from homeassistant.config_entries import SOURCE_USER, ConfigEntry, ConfigFlowContext from homeassistant.core import HomeAssistant @@ -118,6 +124,7 @@ async def test_async_setup_entry_routes_to_sandbox( channel_b.start() manager.install("built-in", channel_a) + mock_integration(hass, MockModule("test_entry")) entry = MockConfigEntry( domain="test_entry", title="Test", @@ -155,6 +162,7 @@ async def test_async_setup_entry_marks_setup_error_on_failure( channel_b.start() manager.install("built-in", channel_a) + mock_integration(hass, MockModule("test_fail")) entry = MockConfigEntry( domain="test_fail", title="Test", @@ -173,6 +181,37 @@ async def test_async_setup_entry_marks_setup_error_on_failure( assert entry.reason == "boom" +async def test_entry_setup_payload_sets_builtin_source(hass: HomeAssistant) -> None: + """A built-in entry's payload carries a ``{kind: builtin}`` source.""" + mock_integration(hass, MockModule("payload_builtin")) + entry = MockConfigEntry(domain="payload_builtin", title="Payload") + + payload = await _entry_setup_payload(hass, cast(ConfigEntry, entry)) + + assert payload.integration_source.kind == "builtin" + + +async def test_entry_setup_payload_sets_git_source(hass: HomeAssistant) -> None: + """A custom entry's payload carries the resolver's pinned git source.""" + mock_integration(hass, MockModule("payload_custom"), built_in=False) + async_register_sandbox_source_resolver( + hass, + lambda domain: { + "kind": "git", + "url": "https://github.com/owner/payload_custom", + "ref": "d" * 40, + "tag": "v2.0.0", + }, + ) + entry = MockConfigEntry(domain="payload_custom", title="Payload") + + payload = await _entry_setup_payload(hass, cast(ConfigEntry, entry)) + + assert payload.integration_source.kind == "git" + assert payload.integration_source.ref == "d" * 40 + assert payload.integration_source.subdir == "custom_components/payload_custom" + + async def test_async_setup_entry_returns_none_when_not_sandboxed( hass: HomeAssistant, manager: FakeSandboxManager ) -> None: diff --git a/tests/components/sandbox_v2/test_sources.py b/tests/components/sandbox_v2/test_sources.py new file mode 100644 index 00000000000..7886314c5f3 --- /dev/null +++ b/tests/components/sandbox_v2/test_sources.py @@ -0,0 +1,134 @@ +"""Tests for the main-side integration-source resolver registry.""" + +import pytest + +from homeassistant.components.sandbox_v2.sources import ( + IntegrationSourceDict, + SandboxSourceError, + async_register_sandbox_source_resolver, + async_resolve_integration_source, +) +from homeassistant.core import HomeAssistant + +from tests.common import MockModule, mock_integration + + +async def test_resolve_builtin_returns_builtin_kind(hass: HomeAssistant) -> None: + """A built-in integration short-circuits to ``{kind: builtin}``.""" + mock_integration(hass, MockModule("demo_builtin")) + + source = await async_resolve_integration_source(hass, "demo_builtin") + + assert source.kind == "builtin" + assert source.url == "" + + +async def test_resolve_builtin_ignores_resolvers(hass: HomeAssistant) -> None: + """Built-ins never consult a resolver, even when one is registered.""" + + def _resolver(domain: str) -> IntegrationSourceDict | None: + raise AssertionError("resolver must not be consulted for built-ins") + + async_register_sandbox_source_resolver(hass, _resolver) + mock_integration(hass, MockModule("demo_builtin2")) + + source = await async_resolve_integration_source(hass, "demo_builtin2") + + assert source.kind == "builtin" + + +async def test_resolve_custom_uses_registered_resolver(hass: HomeAssistant) -> None: + """A custom integration resolves to the registered git source.""" + mock_integration(hass, MockModule("my_custom"), built_in=False) + + def _resolver(domain: str) -> IntegrationSourceDict | None: + assert domain == "my_custom" + return { + "kind": "git", + "url": "https://github.com/owner/my_custom", + "ref": "a" * 40, + "tag": "v1.2.3", + } + + async_register_sandbox_source_resolver(hass, _resolver) + + source = await async_resolve_integration_source(hass, "my_custom") + + assert source.kind == "git" + assert source.url == "https://github.com/owner/my_custom" + assert source.ref == "a" * 40 + assert source.tag == "v1.2.3" + assert source.domain == "my_custom" + # subdir defaults from the domain when the resolver omits it. + assert source.subdir == "custom_components/my_custom" + + +async def test_resolve_custom_without_resolver_raises(hass: HomeAssistant) -> None: + """A custom integration with no resolver cannot run — surface it.""" + mock_integration(hass, MockModule("orphan_custom"), built_in=False) + + with pytest.raises(SandboxSourceError, match="no sandbox source resolver"): + await async_resolve_integration_source(hass, "orphan_custom") + + +async def test_resolve_custom_resolver_returning_none_raises( + hass: HomeAssistant, +) -> None: + """A resolver that doesn't know the domain falls through to the error.""" + mock_integration(hass, MockModule("unknown_custom"), built_in=False) + async_register_sandbox_source_resolver(hass, lambda domain: None) + + with pytest.raises(SandboxSourceError, match="no sandbox source resolver"): + await async_resolve_integration_source(hass, "unknown_custom") + + +async def test_resolve_git_source_without_ref_raises(hass: HomeAssistant) -> None: + """A git source missing its pinned sha is rejected (no moving tags).""" + mock_integration(hass, MockModule("unpinned_custom"), built_in=False) + async_register_sandbox_source_resolver( + hass, + lambda domain: { + "kind": "git", + "url": "https://github.com/owner/unpinned_custom", + "tag": "v1.0.0", + }, + ) + + with pytest.raises(SandboxSourceError, match="must pin"): + await async_resolve_integration_source(hass, "unpinned_custom") + + +async def test_resolvers_consulted_in_order(hass: HomeAssistant) -> None: + """The first resolver returning a source wins.""" + mock_integration(hass, MockModule("ordered_custom"), built_in=False) + async_register_sandbox_source_resolver(hass, lambda domain: None) + async_register_sandbox_source_resolver( + hass, + lambda domain: { + "kind": "git", + "url": "https://github.com/owner/ordered_custom", + "ref": "b" * 40, + }, + ) + + source = await async_resolve_integration_source(hass, "ordered_custom") + + assert source.ref == "b" * 40 + + +async def test_unregister_resolver(hass: HomeAssistant) -> None: + """Unregistering a resolver removes it from the consulted set.""" + mock_integration(hass, MockModule("gone_custom"), built_in=False) + unregister = async_register_sandbox_source_resolver( + hass, + lambda domain: { + "kind": "git", + "url": "https://github.com/owner/gone_custom", + "ref": "c" * 40, + }, + ) + + unregister() + + with pytest.raises(SandboxSourceError): + await async_resolve_integration_source(hass, "gone_custom")