From 006cb40788ca0c92c76e22c8d94bd17b7655912c Mon Sep 17 00:00:00 2001 From: "Mariana R. Santos" Date: Tue, 14 Jan 2025 15:31:38 +0100 Subject: [PATCH] Use IDA MQTT message to update inspection view --- backend/api/EventHandlers/MqttEventHandler.cs | 35 +++++++++++++ .../MQTT/MessageModels/IdaInspectionResult.cs | 35 +++++++++++++ backend/api/MQTT/MqttService.cs | 50 +++++++++++++++++++ backend/api/MQTT/MqttTopics.cs | 1 + backend/api/Utilities/Exceptions.cs | 6 ++- backend/api/appsettings.Development.json | 3 +- backend/api/appsettings.Local.json | 3 +- backend/api/appsettings.Production.json | 3 +- backend/api/appsettings.Staging.json | 3 +- backend/api/appsettings.Test.json | 3 +- broker/mosquitto/config/access_control | 6 +++ frontend/src/App.tsx | 2 +- .../components/Contexts/InpectionsContext.tsx | 37 +++++++++++++- .../components/Contexts/SignalRContext.tsx | 1 + .../InspectionView.tsx | 25 ++-------- frontend/src/models/Inspection.ts | 7 +++ 16 files changed, 190 insertions(+), 30 deletions(-) create mode 100644 backend/api/MQTT/MessageModels/IdaInspectionResult.cs diff --git a/backend/api/EventHandlers/MqttEventHandler.cs b/backend/api/EventHandlers/MqttEventHandler.cs index 880e20b84..9c195902e 100644 --- a/backend/api/EventHandlers/MqttEventHandler.cs +++ b/backend/api/EventHandlers/MqttEventHandler.cs @@ -83,6 +83,7 @@ public override void Subscribe() MqttService.MqttIsarPressureReceived += OnIsarPressureUpdate; MqttService.MqttIsarPoseReceived += OnIsarPoseUpdate; MqttService.MqttIsarCloudHealthReceived += OnIsarCloudHealthUpdate; + MqttService.MqttIdaInspectionResultReceived += OnIdaInspectionResultUpdate; } public override void Unsubscribe() @@ -95,6 +96,7 @@ public override void Unsubscribe() MqttService.MqttIsarPressureReceived -= OnIsarPressureUpdate; MqttService.MqttIsarPoseReceived -= OnIsarPoseUpdate; MqttService.MqttIsarCloudHealthReceived -= OnIsarCloudHealthUpdate; + MqttService.MqttIdaInspectionResultReceived -= OnIdaInspectionResultUpdate; } protected override async Task ExecuteAsync(CancellationToken stoppingToken) @@ -671,5 +673,38 @@ private async void OnIsarCloudHealthUpdate(object? sender, MqttReceivedArgs mqtt TeamsMessageService.TriggerTeamsMessageReceived(new TeamsMessageEventArgs(message)); } + + private async void OnIdaInspectionResultUpdate(object? sender, MqttReceivedArgs mqttArgs) + { + var inspectionResult = (IdaInspectionResultMessage)mqttArgs.Message; + + var inspectionResultMessage = new InspectionResultMessage + { + InspectionId = inspectionResult.InspectionId, + StorageAccount = inspectionResult.StorageAccount, + BlobContainer = inspectionResult.BlobContainer, + BlobName = inspectionResult.BlobName, + }; + + var installation = await InstallationService.ReadByInstallationCode( + inspectionResult.BlobContainer, + readOnly: true + ); + + if (installation == null) + { + _logger.LogError( + "Installation with code {Code} not found when processing IDA inspection result update", + inspectionResult.BlobContainer + ); + return; + } + + _ = SignalRService.SendMessageAsync( + "Inspection Visulization Ready", + installation, + inspectionResultMessage + ); + } } } diff --git a/backend/api/MQTT/MessageModels/IdaInspectionResult.cs b/backend/api/MQTT/MessageModels/IdaInspectionResult.cs new file mode 100644 index 000000000..c63c208b5 --- /dev/null +++ b/backend/api/MQTT/MessageModels/IdaInspectionResult.cs @@ -0,0 +1,35 @@ +using System.Text.Json.Serialization; + +namespace Api.Mqtt.MessageModels +{ +#nullable disable + public class IdaInspectionResultMessage : MqttMessage + { + [JsonPropertyName("inspection_id")] + public string InspectionId { get; set; } + + [JsonPropertyName("storageAccount")] + public required string StorageAccount { get; set; } + + [JsonPropertyName("blobContainer")] + public required string BlobContainer { get; set; } + + [JsonPropertyName("blobName")] + public required string BlobName { get; set; } + } + + public class InspectionResultMessage + { + [JsonPropertyName("inspectionId")] + public string InspectionId { get; set; } + + [JsonPropertyName("storageAccount")] + public required string StorageAccount { get; set; } + + [JsonPropertyName("blobContainer")] + public required string BlobContainer { get; set; } + + [JsonPropertyName("blobName")] + public required string BlobName { get; set; } + } +} diff --git a/backend/api/MQTT/MqttService.cs b/backend/api/MQTT/MqttService.cs index 5b3f2b7aa..9c791a27f 100644 --- a/backend/api/MQTT/MqttService.cs +++ b/backend/api/MQTT/MqttService.cs @@ -85,6 +85,7 @@ public MqttService(ILogger logger, IConfiguration config) public static event EventHandler? MqttIsarPressureReceived; public static event EventHandler? MqttIsarPoseReceived; public static event EventHandler? MqttIsarCloudHealthReceived; + public static event EventHandler? MqttIdaInspectionResultReceived; protected override async Task ExecuteAsync(CancellationToken stoppingToken) { @@ -143,6 +144,9 @@ private Task OnMessageReceived(MqttApplicationMessageReceivedEventArgs messageRe case Type type when type == typeof(IsarCloudHealthMessage): OnIsarTopicReceived(content); break; + case Type type when type == typeof(IdaInspectionResultMessage): + OnIdaTopicReceived(content); + break; default: _logger.LogWarning( "No callback defined for MQTT message type '{type}'", @@ -303,5 +307,51 @@ private void OnIsarTopicReceived(string content) _logger.LogWarning("{msg}", e.Message); } } + + private void OnIdaTopicReceived(string content) + where T : MqttMessage + { + T? message; + + try + { + message = JsonSerializer.Deserialize(content, serializerOptions); + if (message is null) + { + throw new JsonException(); + } + } + catch (Exception ex) + when (ex is JsonException or NotSupportedException or ArgumentException) + { + _logger.LogError( + "Could not create '{className}' object from MQTT message json", + typeof(T).Name + ); + return; + } + + var type = typeof(T); + try + { + var raiseEvent = type switch + { + _ when type == typeof(IdaInspectionResultMessage) => + MqttIdaInspectionResultReceived, + _ => throw new NotImplementedException( + $"No event defined for message type '{typeof(T).Name}'" + ), + }; + // Event will be null if there are no subscribers + if (raiseEvent is not null) + { + raiseEvent(this, new MqttReceivedArgs(message)); + } + } + catch (NotImplementedException e) + { + _logger.LogWarning("{msg}", e.Message); + } + } } } diff --git a/backend/api/MQTT/MqttTopics.cs b/backend/api/MQTT/MqttTopics.cs index 93f052905..427a27183 100644 --- a/backend/api/MQTT/MqttTopics.cs +++ b/backend/api/MQTT/MqttTopics.cs @@ -23,6 +23,7 @@ public static class MqttTopics { "isar/+/pressure", typeof(IsarPressureMessage) }, { "isar/+/pose", typeof(IsarPoseMessage) }, { "isar/+/cloud_health", typeof(IsarCloudHealthMessage) }, + { "ida/visualization_available", typeof(IdaInspectionResultMessage) }, }; /// diff --git a/backend/api/Utilities/Exceptions.cs b/backend/api/Utilities/Exceptions.cs index 09c12ea2b..8bbaa1441 100644 --- a/backend/api/Utilities/Exceptions.cs +++ b/backend/api/Utilities/Exceptions.cs @@ -57,14 +57,16 @@ public class RobotNotAvailableException(string message) : Exception(message) { } public class RobotBusyException(string message) : Exception(message) { } public class RobotNotInSameInstallationAsMissionException(string message) - : Exception(message) { } + : Exception(message) + { } public class PoseNotFoundException(string message) : Exception(message) { } public class IsarCommunicationException(string message) : Exception(message) { } public class ReturnToHomeMissionFailedToScheduleException(string message) - : Exception(message) { } + : Exception(message) + { } public class RobotCurrentAreaMissingException(string message) : Exception(message) { } diff --git a/backend/api/appsettings.Development.json b/backend/api/appsettings.Development.json index 5088861f1..64053e1b6 100644 --- a/backend/api/appsettings.Development.json +++ b/backend/api/appsettings.Development.json @@ -36,7 +36,8 @@ "isar/+/pressure", "isar/+/pose", "isar/+/cloud_health", - "isar/+/media_config" + "isar/+/media_config", + "ida/visualization_available" ], "MaxRetryAttempts": 5, "ShouldFailOnMaxRetries": false diff --git a/backend/api/appsettings.Local.json b/backend/api/appsettings.Local.json index 7e6c43aa5..9a219d202 100644 --- a/backend/api/appsettings.Local.json +++ b/backend/api/appsettings.Local.json @@ -36,7 +36,8 @@ "isar/+/pressure", "isar/+/pose", "isar/+/cloud_health", - "isar/+/media_config" + "isar/+/media_config", + "ida/visualization_available" ], "MaxRetryAttempts": 5, "ShouldFailOnMaxRetries": false diff --git a/backend/api/appsettings.Production.json b/backend/api/appsettings.Production.json index 8513f6808..1f18afb59 100644 --- a/backend/api/appsettings.Production.json +++ b/backend/api/appsettings.Production.json @@ -32,7 +32,8 @@ "isar/+/pressure", "isar/+/pose", "isar/+/cloud_health", - "isar/+/media_config" + "isar/+/media_config", + "ida/visualization_available" ], "MaxRetryAttempts": 15, "ShouldFailOnMaxRetries": true diff --git a/backend/api/appsettings.Staging.json b/backend/api/appsettings.Staging.json index cb10ac712..5e86ba6e6 100644 --- a/backend/api/appsettings.Staging.json +++ b/backend/api/appsettings.Staging.json @@ -36,7 +36,8 @@ "isar/+/pressure", "isar/+/pose", "isar/+/cloud_health", - "isar/+/media_config" + "isar/+/media_config", + "ida/visualization_available" ], "MaxRetryAttempts": 15, "ShouldFailOnMaxRetries": true diff --git a/backend/api/appsettings.Test.json b/backend/api/appsettings.Test.json index e9e96baf2..a2d52423c 100644 --- a/backend/api/appsettings.Test.json +++ b/backend/api/appsettings.Test.json @@ -30,7 +30,8 @@ "isar/+/pressure", "isar/+/pose", "isar/+/cloud_health", - "isar/+/media_config" + "isar/+/media_config", + "ida/visualization_available" ], "MaxRetryAttempts": 15, "ShouldFailOnMaxRetries": true diff --git a/broker/mosquitto/config/access_control b/broker/mosquitto/config/access_control index c3f5b0b6c..af8956eac 100644 --- a/broker/mosquitto/config/access_control +++ b/broker/mosquitto/config/access_control @@ -7,8 +7,14 @@ topic readwrite isar/# user flotilla topic read isar/# +user flotilla +topic read ida/# + user analytics topic read isar/+/inspection_result user ida topic read isar/+/inspection_result + +user ida +topic write ida/visualization_available diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 58acf4a3f..d10f69afe 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -29,7 +29,7 @@ if (config.AI_CONNECTION_STRING.length > 0) { appInsights.trackPageView() } -const queryClient = new QueryClient() +export const queryClient = new QueryClient() const App = () => ( diff --git a/frontend/src/components/Contexts/InpectionsContext.tsx b/frontend/src/components/Contexts/InpectionsContext.tsx index 2e437219d..053aae11a 100644 --- a/frontend/src/components/Contexts/InpectionsContext.tsx +++ b/frontend/src/components/Contexts/InpectionsContext.tsx @@ -1,9 +1,15 @@ -import { createContext, FC, useContext, useState } from 'react' +import { createContext, FC, useContext, useEffect, useState } from 'react' import { Task } from 'models/Task' +import { SignalREventLabels, useSignalRContext } from './SignalRContext' +import { IdaInspectionVisualizationReady } from 'models/Inspection' +import { useQuery } from '@tanstack/react-query' +import { BackendAPICaller } from 'api/ApiCaller' +import { queryClient } from '../../App' interface IInspectionsContext { selectedInspectionTask: Task | undefined switchSelectedInspectionTask: (selectedInspectionTask: Task | undefined) => void + fetchImageData: (inspectionId: string) => any } interface Props { @@ -13,22 +19,51 @@ interface Props { const defaultInspectionsContext = { selectedInspectionTask: undefined, switchSelectedInspectionTask: () => undefined, + fetchImageData: () => undefined, } const InspectionsContext = createContext(defaultInspectionsContext) export const InspectionsProvider: FC = ({ children }) => { + const { registerEvent, connectionReady } = useSignalRContext() const [selectedInspectionTask, setSelectedInspectionTask] = useState() + useEffect(() => { + if (connectionReady) { + registerEvent(SignalREventLabels.inspectionVisualizationReady, (username: string, message: string) => { + const inspectionVisualizationData: IdaInspectionVisualizationReady = JSON.parse(message) + queryClient.invalidateQueries({ + queryKey: ['fetchInspectionData', inspectionVisualizationData.inspectionId], + }) + fetchImageData(inspectionVisualizationData.inspectionId) + }) + } + }, [registerEvent, connectionReady]) + const switchSelectedInspectionTask = (selectedTask: Task | undefined) => { setSelectedInspectionTask(selectedTask) } + const fetchImageData = (inspectionId: string) => { + const data = useQuery({ + queryKey: ['fetchInspectionData', inspectionId], + queryFn: async () => { + const imageBlob = await BackendAPICaller.getInspection(inspectionId) + return URL.createObjectURL(imageBlob) + }, + retry: 1, + staleTime: 10 * 60 * 1000, // I don't want an API call for 10 min after the first time I get data + enabled: inspectionId !== undefined, + }) + return data + } + return ( {children} diff --git a/frontend/src/components/Contexts/SignalRContext.tsx b/frontend/src/components/Contexts/SignalRContext.tsx index 0a63c0ba3..bd17a817e 100644 --- a/frontend/src/components/Contexts/SignalRContext.tsx +++ b/frontend/src/components/Contexts/SignalRContext.tsx @@ -123,4 +123,5 @@ export enum SignalREventLabels { inspectionUpdated = 'Inspection updated', alert = 'Alert', mediaStreamConfigReceived = 'Media stream config received', + inspectionVisualizationReady = 'Inspection Visulization Ready', } diff --git a/frontend/src/components/Pages/InspectionReportPage.tsx/InspectionView.tsx b/frontend/src/components/Pages/InspectionReportPage.tsx/InspectionView.tsx index 0f7157755..c13244a3e 100644 --- a/frontend/src/components/Pages/InspectionReportPage.tsx/InspectionView.tsx +++ b/frontend/src/components/Pages/InspectionReportPage.tsx/InspectionView.tsx @@ -22,8 +22,6 @@ import { StyledInspectionImage, StyledSection, } from './InspectionStyles' -import { BackendAPICaller } from 'api/ApiCaller' -import { useQuery } from '@tanstack/react-query' interface InspectionDialogViewProps { task: Task @@ -34,7 +32,8 @@ export const InspectionDialogView = ({ task, tasks }: InspectionDialogViewProps) const { TranslateText } = useLanguageContext() const { installationName } = useInstallationContext() const { switchSelectedInspectionTask } = useInspectionsContext() - const { data } = FetchImageData(task) + const { fetchImageData } = useInspectionsContext() + const { data } = fetchImageData(task.inspection.isarInspectionId) const closeDialog = () => { switchSelectedInspectionTask(undefined) @@ -155,28 +154,12 @@ export const InspectionsViewSection = ({ tasks, dialogView }: InspectionsViewSec ) } -const FetchImageData = (task: Task) => { - const data = useQuery({ - queryKey: ['fetchInspectionData', task.isarTaskId], - queryFn: async () => { - const imageBlob = await BackendAPICaller.getInspection(task.inspection.isarInspectionId) - return URL.createObjectURL(imageBlob) - }, - retryDelay: 60 * 1000, // Will always wait 1 min to retry, regardless of how many retries - staleTime: 10 * 60 * 1000, // I don't want an API call for 10 min after the first time I get data - enabled: - task.status === TaskStatus.Successful && - task.isarTaskId !== undefined && - task.inspection.isarInspectionId !== undefined, - }) - return data -} - interface IGetInspectionImageProps { task: Task } const GetInspectionImage = ({ task }: IGetInspectionImageProps) => { - const { data } = FetchImageData(task) + const { fetchImageData } = useInspectionsContext() + const { data } = fetchImageData(task.inspection.isarInspectionId) return <>{data !== undefined && } } diff --git a/frontend/src/models/Inspection.ts b/frontend/src/models/Inspection.ts index 0ba95a8f7..ba8ec972c 100644 --- a/frontend/src/models/Inspection.ts +++ b/frontend/src/models/Inspection.ts @@ -30,3 +30,10 @@ export enum InspectionType { ThermalVideo = 'ThermalVideo', Audio = 'Audio', } + +export interface IdaInspectionVisualizationReady { + inspectionId: string + storageAccount: string + blobContainer: string + blobName: string +}