From 48de551fe30bbe8af4b2ae9ffaaac0f8f2474912 Mon Sep 17 00:00:00 2001 From: Patrick Baxter Date: Tue, 28 Jan 2025 22:41:31 +1300 Subject: [PATCH 01/14] add reference-image, ability to set location and type --- api/api/V1/Device.ts | 1060 +++++++++++++++++++++-------------- api/api/V1/recordingUtil.ts | 2 + types/api/device.d.ts | 2 + 3 files changed, 650 insertions(+), 414 deletions(-) diff --git a/api/api/V1/Device.ts b/api/api/V1/Device.ts index f6a4c595..3d6d9b2b 100755 --- a/api/api/V1/Device.ts +++ b/api/api/V1/Device.ts @@ -89,6 +89,8 @@ import { mapStation } from "@api/V1/Station.js"; import { mapTrack } from "@api/V1/Recording.js"; import { createEntityJWT } from "@api/auth.js"; import logger from "@log"; +import { tryToMatchLocationToStationInGroup } from "@/models/util/locationUtils.js"; +import { deleteFile } from "@/models/util/util.js"; const models = await modelsInit(); @@ -215,6 +217,11 @@ interface ApiDeviceSettingsResponseSuccess { settings: ApiDeviceHistorySettings; } +// eslint-disable-next-line @typescript-eslint/no-unused-vars +interface ApiDeviceTypeResponseSuccess { + type: DeviceType; +} + // eslint-disable-next-line @typescript-eslint/no-unused-vars interface ApiDeviceUsersResponseSuccess { users: ApiGroupUserResponse[]; @@ -256,6 +263,7 @@ export default function (app: Application, baseUrl: string) { deviceName: request.body.deviceName, password: request.body.password, GroupId: response.locals.group.id, + kind: request.body.deviceType, }); let saltId; if (request.body.saltId) { @@ -563,421 +571,123 @@ export default function (app: Application, baseUrl: string) { }); } ); - /** - * @api {get} /api/v1/devices/:deviceId/reference-image Get the reference image (if any) for a device - * @apiName GetDeviceReferenceImageAtTime + * @api {get} /api/v1/devices/:deviceId/location Get the location for a device at a given time + * @apiName GetDeviceLocationAtTime * @apiGroup Device * @apiParam {Integer} deviceId Id of the device - * @apiParam {String} exists If set to 'exists' returns whether the device has a reference image at the given time. * @apiQuery {String} [at-time] ISO8601 formatted date string for when the reference image should be current. - * @apiQuery {String} [type] Can be 'pov' for point-of-view reference image or 'in-situ' for a reference image showing device placement in the environment. * - * @apiDescription Returns a reference image for a device (if any has been set) at a given point in time, or now, + * @apiDescription Returns the location (station) for a device at a given point in time, or now, * if no date time is specified * * @apiUse V1UserAuthorizationHeader * * @apiUse V1ResponseSuccess - * @apiSuccess binary data of reference image + * @apiInterface {apiSuccess::ApiLocationResponseSuccess} station Device location details * @apiUse V1ResponseError */ app.get( - `${apiUrl}/:id/reference-image/:exists?`, + `${apiUrl}/:id/location`, extractJwtAuthorizedUser, validateFields([ idOf(param("id")), - param("exists").optional(), query("view-mode").optional().equals("user"), query("at-time").isISO8601().toDate().optional(), - query("type").optional().isIn(["pov", "in-situ"]), booleanOf(query("only-active"), false), ]), fetchAuthorizedRequiredDeviceById(param("id")), async (request: Request, response: Response, next: NextFunction) => { - const checkIfExists = request.params.exists === "exists"; const atTime = (request.query["at-time"] && (request.query["at-time"] as unknown as Date)) || new Date(); const device = response.locals.device as Device; - const deviceHistoryEntry: DeviceHistory | null = - await models.DeviceHistory.latest(device.id, device.GroupId, atTime); - if (!deviceHistoryEntry) { - return next( - new UnprocessableError( - "No reference image available for device at time" - ) - ); - } - if (device.kind === DeviceType.TrailCam) { - // NOTE: If the device is a trailcam, try and use the daytime image that closest matches the requested time, if any. - - // The trailcam has been in this location since this time. - const fromTime = deviceHistoryEntry.fromDateTime; - if (!fromTime) { - return next( - new UnprocessableError( - "No reference image available for device at time" - ) - ); - } - let recording: any; - // See if this device has a later location - const laterDeviceHistoryEntry: DeviceHistory | null = - await models.DeviceHistory.findOne({ - where: [ + const deviceHistoryEntry = await models.DeviceHistory.findOne({ + where: { + DeviceId: device.id, + GroupId: device.GroupId, + location: { [Op.ne]: null }, + fromDateTime: { [Op.lte]: atTime }, + }, + include: [ + { + model: models.Station, + include: [ { - DeviceId: device.id, - GroupId: device.GroupId, - fromDateTime: { [Op.gt]: fromTime }, + model: models.Group, + attributes: ["groupName"], }, - models.sequelize.where( - Sequelize.fn("ST_X", Sequelize.col("location")), - { [Op.ne]: deviceHistoryEntry.location.lng } - ), - models.sequelize.where( - Sequelize.fn("ST_Y", Sequelize.col("location")), - { [Op.ne]: deviceHistoryEntry.location.lat } - ), - ] as any, - order: [["fromDateTime", "ASC"]], - }); - const payload: { fromDateTime: Date; untilDateTime?: Date } = { - fromDateTime: fromTime, + ], + }, + ], + order: [["fromDateTime", "DESC"]], + }); + if (deviceHistoryEntry && deviceHistoryEntry.Station) { + return successResponse(response, "Got location for device at time", { + location: mapStation(deviceHistoryEntry.Station), + }); + } + return next( + new UnprocessableError("No location recorded for device at time") + ); + } + ); + + app.get( + `${apiUrl}/:id/tracks-with-tag/:tag`, + extractJwtAuthorizedUser, + validateFields([ + idOf(param("id")), + param("tag").isString(), + query("view-mode").optional().equals("user"), + query("from-time").isISO8601().toDate().optional(), + query("until-time").isISO8601().toDate().optional(), + booleanOf(query("only-active"), false), + ]), + fetchAuthorizedRequiredDeviceById(param("id")), + async (request: Request, response: Response, _next: NextFunction) => { + const fromTime = + request.query["from-time"] && + (request.query["from-time"] as unknown as Date); + const untilTime = + (request.query["until-time"] && + (request.query["until-time"] as unknown as Date)) || + new Date(); + + const timeWindow = {}; + if (fromTime) { + (timeWindow as any).recordingDateTime = { + [Op.and]: [{ [Op.gt]: fromTime }, { [Op.lte]: untilTime }], }; - if (laterDeviceHistoryEntry) { - payload.untilDateTime = laterDeviceHistoryEntry.fromDateTime; - // Now check if there's a daytime image in that timespan, preferably without any tracks, - // to avoid there being animals present - const options = { - type: QueryTypes.SELECT, - replacements: { - groupId: device.GroupId, - deviceId: device.id, - atTime: fromTime, - untilTime: laterDeviceHistoryEntry.fromDateTime, + } + const tag = request.params.tag; + const tracks = await models.Track.findAll({ + raw: true, + where: { + archivedAt: { [Op.eq]: null }, + }, + include: [ + { + model: models.TrackTag, + required: true, + where: { + [Op.and]: { + [Op.or]: [ + { automatic: { [Op.eq]: true }, "data.name": "Master" }, + { automatic: { [Op.eq]: false } }, + ], + }, }, - }; - recording = await models.sequelize.query( - ` - select * from "Recordings" - left outer join "Tracks" - on "Tracks"."RecordingId" = "Tracks".id - where "DeviceId" = :deviceId - and "GroupId" = :groupId - and location is not null - and "recordingDateTime" >= :atTime - and "recordingDateTime" < :untilTime - and type = 'trailcam-image' - and "Tracks".id is null - and CAST (("recordingDateTime" AT TIME ZONE 'NZST') AS time) - BETWEEN TIME '9:00' AND TIME '16:00' - order by "recordingDateTime" desc - where - limit 1 - `, - options - ); - if (!recording.length) { - recording = await models.sequelize.query( - ` - select * from "Recordings" - where "DeviceId" = :deviceId - and "GroupId" = :groupId - and location is not null - and "recordingDateTime" >= :atTime - and "recordingDateTime" < :untilTime - and type = 'trailcam-image' - and CAST (("recordingDateTime" AT TIME ZONE 'NZST') AS time) - BETWEEN TIME '9:00' AND TIME '16:00' - order by "recordingDateTime" desc - limit 1 - `, - options - ); - } - } else { - // Now check if there's a daytime image in that timespan, preferably without any tracks, - // to avoid there being animals present - const options = { - type: QueryTypes.SELECT, - replacements: { - groupId: device.GroupId, - deviceId: device.id, - atTime: fromTime, - }, - }; - recording = await models.sequelize.query( - ` - select * from "Recordings" - where "DeviceId" = :deviceId - and "GroupId" = :groupId - and location is not null - and "recordingDateTime" >= :atTime - and type = 'trailcam-image' - and CAST (("recordingDateTime" AT TIME ZONE 'NZST') AS time) - BETWEEN TIME '9:00' AND TIME '16:00' - order by "recordingDateTime" desc - limit 1 - `, - options - ); - if (!recording.length) { - recording = await models.sequelize.query( - ` - select * from "Recordings" - where "DeviceId" = :deviceId - and "GroupId" = :groupId - and location is not null - and "recordingDateTime" >= :atTime - and type = 'trailcam-image' - and CAST (("recordingDateTime" AT TIME ZONE 'NZST') AS time) - BETWEEN TIME '9:00' AND TIME '16:00' - order by "recordingDateTime" desc - limit 1 - `, - options - ); - } - } - if (recording.length) { - if (checkIfExists) { - return successResponse( - response, - "Reference image exists at supplied time", - payload - ); - } else { - // Actually return the image. - const mimeType = "image/webp"; // Or something better - const time = fromTime - ?.toISOString() - .replace(/:/g, "_") - .replace(".", "_"); - const filename = `device-${device.id}-reference-image@${time}.webp`; - // Get reference image for device at time if any. - return streamS3Object( - request, - response, - recording[0].fileKey, - filename, - mimeType, - response.locals.requestUser.id, - device.GroupId, - recording[0].fileSize - ); - } - } else { - return next( - new UnprocessableError( - "No reference image available for device at time" - ) - ); - } - } else if ( - [DeviceType.Hybrid, DeviceType.Thermal].includes(device.kind) - ) { - const kind = request.query.type || "pov"; - let referenceImage; - let referenceImageFileSize; - if (kind === "pov") { - referenceImage = deviceHistoryEntry?.settings?.referenceImagePOV; - referenceImageFileSize = - deviceHistoryEntry?.settings?.referenceImagePOVFileSize; - } else { - referenceImage = deviceHistoryEntry?.settings?.referenceImageInSitu; - referenceImageFileSize = - deviceHistoryEntry?.settings?.referenceImageInSituFileSize; - } - const fromTime = deviceHistoryEntry?.fromDateTime; - if (referenceImage && fromTime && referenceImageFileSize) { - if (checkIfExists) { - // We want to return the earliest time after creation that this reference image is valid for too, so that the client only - // needs to query this API occasionally. - const laterDeviceHistoryEntry: DeviceHistory = - await models.DeviceHistory.findOne({ - where: [ - { - DeviceId: device.id, - GroupId: device.GroupId, - fromDateTime: { [Op.gt]: fromTime }, - }, - models.sequelize.where( - Sequelize.fn("ST_X", Sequelize.col("location")), - { [Op.ne]: deviceHistoryEntry.location.lng } - ), - models.sequelize.where( - Sequelize.fn("ST_Y", Sequelize.col("location")), - { [Op.ne]: deviceHistoryEntry.location.lat } - ), - ] as any, - order: [["fromDateTime", "ASC"]], - }); - const payload: { fromDateTime: Date; untilDateTime?: Date } = { - fromDateTime: fromTime, - }; - if (laterDeviceHistoryEntry) { - payload.untilDateTime = laterDeviceHistoryEntry.fromDateTime; - } - return successResponse( - response, - "Reference image exists at supplied time", - payload - ); - } else { - // Get reference image for device at time if any, and return it - const mimeType = "image/webp"; // Or something better - const time = fromTime - ?.toISOString() - .replace(/:/g, "_") - .replace(".", "_"); - const filename = `device-${device.id}-reference-image@${time}.webp`; - // Get reference image for device at time if any. - return streamS3Object( - request, - response, - referenceImage, - filename, - mimeType, - response.locals.requestUser.id, - device.GroupId, - referenceImageFileSize - ); - } - } - return next( - new UnprocessableError( - "No reference image available for device at time" - ) - ); - } else { - return next( - new UnprocessableError( - `Reference images not supported for ${device.kind} devices.` - ) - ); - } - } - ); - - /** - * @api {get} /api/v1/devices/:deviceId/location Get the location for a device at a given time - * @apiName GetDeviceLocationAtTime - * @apiGroup Device - * @apiParam {Integer} deviceId Id of the device - * @apiQuery {String} [at-time] ISO8601 formatted date string for when the reference image should be current. - * - * @apiDescription Returns the location (station) for a device at a given point in time, or now, - * if no date time is specified - * - * @apiUse V1UserAuthorizationHeader - * - * @apiUse V1ResponseSuccess - * @apiInterface {apiSuccess::ApiLocationResponseSuccess} station Device location details - * @apiUse V1ResponseError - */ - app.get( - `${apiUrl}/:id/location`, - extractJwtAuthorizedUser, - validateFields([ - idOf(param("id")), - query("view-mode").optional().equals("user"), - query("at-time").isISO8601().toDate().optional(), - booleanOf(query("only-active"), false), - ]), - fetchAuthorizedRequiredDeviceById(param("id")), - async (request: Request, response: Response, next: NextFunction) => { - const atTime = - (request.query["at-time"] && - (request.query["at-time"] as unknown as Date)) || - new Date(); - const device = response.locals.device as Device; - const deviceHistoryEntry = await models.DeviceHistory.findOne({ - where: { - DeviceId: device.id, - GroupId: device.GroupId, - location: { [Op.ne]: null }, - fromDateTime: { [Op.lte]: atTime }, - }, - include: [ - { - model: models.Station, - include: [ - { - model: models.Group, - attributes: ["groupName"], - }, - ], - }, - ], - order: [["fromDateTime", "DESC"]], - }); - if (deviceHistoryEntry && deviceHistoryEntry.Station) { - return successResponse(response, "Got location for device at time", { - location: mapStation(deviceHistoryEntry.Station), - }); - } - return next( - new UnprocessableError("No location recorded for device at time") - ); - } - ); - - app.get( - `${apiUrl}/:id/tracks-with-tag/:tag`, - extractJwtAuthorizedUser, - validateFields([ - idOf(param("id")), - param("tag").isString(), - query("view-mode").optional().equals("user"), - query("from-time").isISO8601().toDate().optional(), - query("until-time").isISO8601().toDate().optional(), - booleanOf(query("only-active"), false), - ]), - fetchAuthorizedRequiredDeviceById(param("id")), - async (request: Request, response: Response, _next: NextFunction) => { - const fromTime = - request.query["from-time"] && - (request.query["from-time"] as unknown as Date); - const untilTime = - (request.query["until-time"] && - (request.query["until-time"] as unknown as Date)) || - new Date(); - - const timeWindow = {}; - if (fromTime) { - (timeWindow as any).recordingDateTime = { - [Op.and]: [{ [Op.gt]: fromTime }, { [Op.lte]: untilTime }], - }; - } - const tag = request.params.tag; - const tracks = await models.Track.findAll({ - raw: true, - where: { - archivedAt: { [Op.eq]: null }, - }, - include: [ - { - model: models.TrackTag, - required: true, - where: { - [Op.and]: { - [Op.or]: [ - { automatic: { [Op.eq]: true }, "data.name": "Master" }, - { automatic: { [Op.eq]: false } }, - ], - }, - }, - attributes: ["automatic", "what"], - }, - { - model: models.Recording, - required: true, - where: { - DeviceId: response.locals.device.id, - GroupId: response.locals.device.GroupId, - ...timeWindow, + attributes: ["automatic", "what"], + }, + { + model: models.Recording, + required: true, + where: { + DeviceId: response.locals.device.id, + GroupId: response.locals.device.GroupId, + ...timeWindow, }, attributes: [], }, @@ -1173,10 +883,334 @@ export default function (app: Application, baseUrl: string) { new Date(a.fromDateTime).getTime() ); } - ); - return successResponse(response, "Got locations for device", { - locations, - }); + ); + return successResponse(response, "Got locations for device", { + locations, + }); + } + ); + const ALLOWED_MIME_TYPES = [ + "image/jpeg", + "image/png", + "image/webp", + "image/gif", + ] as const; + const MIME_TO_EXTENSION: Record = { + "image/jpeg": "jpg", + "image/png": "png", + "image/webp": "webp", + "image/gif": "gif", + }; + + // Helper to get file extension from MIME type + const getExtension = (mimeType: string) => + MIME_TO_EXTENSION[mimeType] || "webp"; + /** + * @api {get} /api/v1/devices/:deviceId/reference-image Get the reference image (if any) for a device + * @apiName GetDeviceReferenceImageAtTime + * @apiGroup Device + * @apiParam {Integer} deviceId Id of the device + * @apiParam {String} exists If set to 'exists' returns whether the device has a reference image at the given time. + * @apiQuery {String} [at-time] ISO8601 formatted date string for when the reference image should be current. + * @apiQuery {String} [type] Can be 'pov' for point-of-view reference image or 'in-situ' for a reference image showing device placement in the environment. + * + * @apiDescription Returns a reference image for a device (if any has been set) at a given point in time, or now, + * if no date time is specified + * + * @apiUse V1UserAuthorizationHeader + * + * @apiUse V1ResponseSuccess + * @apiSuccess binary data of reference image + * @apiUse V1ResponseError + */ + app.get( + `${apiUrl}/:id/reference-image/:exists?`, + extractJwtAuthorizedUser, + validateFields([ + idOf(param("id")), + param("exists").optional(), + query("view-mode").optional().equals("user"), + query("at-time").isISO8601().toDate().optional(), + query("type").optional().isIn(["pov", "in-situ"]), + booleanOf(query("only-active"), false), + ]), + fetchAuthorizedRequiredDeviceById(param("id")), + async (request: Request, response: Response, next: NextFunction) => { + const checkIfExists = request.params.exists === "exists"; + const atTime = + (request.query["at-time"] && + (request.query["at-time"] as unknown as Date)) || + new Date(); + const device = response.locals.device as Device; + const deviceHistoryEntry: DeviceHistory | null = + await models.DeviceHistory.latest(device.id, device.GroupId, atTime); + if (!deviceHistoryEntry) { + return next( + new UnprocessableError( + "No reference image available for device at time" + ) + ); + } + if (device.kind === DeviceType.TrailCam) { + // NOTE: If the device is a trailcam, try and use the daytime image that closest matches the requested time, if any. + + // The trailcam has been in this location since this time. + const fromTime = deviceHistoryEntry.fromDateTime; + if (!fromTime) { + return next( + new UnprocessableError( + "No reference image available for device at time" + ) + ); + } + let recording: any; + // See if this device has a later location + const laterDeviceHistoryEntry: DeviceHistory | null = + await models.DeviceHistory.findOne({ + where: [ + { + DeviceId: device.id, + GroupId: device.GroupId, + fromDateTime: { [Op.gt]: fromTime }, + }, + models.sequelize.where( + Sequelize.fn("ST_X", Sequelize.col("location")), + { [Op.ne]: deviceHistoryEntry.location.lng } + ), + models.sequelize.where( + Sequelize.fn("ST_Y", Sequelize.col("location")), + { [Op.ne]: deviceHistoryEntry.location.lat } + ), + ] as any, + order: [["fromDateTime", "ASC"]], + }); + const payload: { fromDateTime: Date; untilDateTime?: Date } = { + fromDateTime: fromTime, + }; + if (laterDeviceHistoryEntry) { + payload.untilDateTime = laterDeviceHistoryEntry.fromDateTime; + // Now check if there's a daytime image in that timespan, preferably without any tracks, + // to avoid there being animals present + const options = { + type: QueryTypes.SELECT, + replacements: { + groupId: device.GroupId, + deviceId: device.id, + atTime: fromTime, + untilTime: laterDeviceHistoryEntry.fromDateTime, + }, + }; + recording = await models.sequelize.query( + ` + select * from "Recordings" + left outer join "Tracks" + on "Tracks"."RecordingId" = "Tracks".id + where "DeviceId" = :deviceId + and "GroupId" = :groupId + and location is not null + and "recordingDateTime" >= :atTime + and "recordingDateTime" < :untilTime + and type = 'trailcam-image' + and "Tracks".id is null + and CAST (("recordingDateTime" AT TIME ZONE 'NZST') AS time) + BETWEEN TIME '9:00' AND TIME '16:00' + order by "recordingDateTime" desc + where + limit 1 + `, + options + ); + if (!recording.length) { + recording = await models.sequelize.query( + ` + select * from "Recordings" + where "DeviceId" = :deviceId + and "GroupId" = :groupId + and location is not null + and "recordingDateTime" >= :atTime + and "recordingDateTime" < :untilTime + and type = 'trailcam-image' + and CAST (("recordingDateTime" AT TIME ZONE 'NZST') AS time) + BETWEEN TIME '9:00' AND TIME '16:00' + order by "recordingDateTime" desc + limit 1 + `, + options + ); + } + } else { + // Now check if there's a daytime image in that timespan, preferably without any tracks, + // to avoid there being animals present + const options = { + type: QueryTypes.SELECT, + replacements: { + groupId: device.GroupId, + deviceId: device.id, + atTime: fromTime, + }, + }; + recording = await models.sequelize.query( + ` + select * from "Recordings" + where "DeviceId" = :deviceId + and "GroupId" = :groupId + and location is not null + and "recordingDateTime" >= :atTime + and type = 'trailcam-image' + and CAST (("recordingDateTime" AT TIME ZONE 'NZST') AS time) + BETWEEN TIME '9:00' AND TIME '16:00' + order by "recordingDateTime" desc + limit 1 + `, + options + ); + if (!recording.length) { + recording = await models.sequelize.query( + ` + select * from "Recordings" + where "DeviceId" = :deviceId + and "GroupId" = :groupId + and location is not null + and "recordingDateTime" >= :atTime + and type = 'trailcam-image' + and CAST (("recordingDateTime" AT TIME ZONE 'NZST') AS time) + BETWEEN TIME '9:00' AND TIME '16:00' + order by "recordingDateTime" desc + limit 1 + `, + options + ); + } + } + if (recording.length) { + if (checkIfExists) { + return successResponse( + response, + "Reference image exists at supplied time", + payload + ); + } else { + // Actually return the image. + const mimeType = "image/webp"; // Or something better + const time = fromTime + ?.toISOString() + .replace(/:/g, "_") + .replace(".", "_"); + const filename = `device-${device.id}-reference-image@${time}.webp`; + // Get reference image for device at time if any. + return streamS3Object( + request, + response, + recording[0].fileKey, + filename, + mimeType, + response.locals.requestUser.id, + device.GroupId, + recording[0].fileSize + ); + } + } else { + return next( + new UnprocessableError( + "No reference image available for device at time" + ) + ); + } + } else if ( + [DeviceType.Hybrid, DeviceType.Thermal].includes(device.kind) + ) { + const kind = request.query.type || "pov"; + let referenceImage; + let referenceImageFileSize; + if (kind === "pov") { + referenceImage = deviceHistoryEntry?.settings?.referenceImagePOV; + referenceImageFileSize = + deviceHistoryEntry?.settings?.referenceImagePOVFileSize; + } else { + referenceImage = deviceHistoryEntry?.settings?.referenceImageInSitu; + referenceImageFileSize = + deviceHistoryEntry?.settings?.referenceImageInSituFileSize; + } + const fromTime = deviceHistoryEntry?.fromDateTime; + if (referenceImage && fromTime && referenceImageFileSize) { + if (checkIfExists) { + // We want to return the earliest time after creation that this reference image is valid for too, so that the client only + // needs to query this API occasionally. + const laterDeviceHistoryEntry: DeviceHistory = + await models.DeviceHistory.findOne({ + where: [ + { + DeviceId: device.id, + GroupId: device.GroupId, + fromDateTime: { [Op.gt]: fromTime }, + }, + models.sequelize.where( + Sequelize.fn("ST_X", Sequelize.col("location")), + { [Op.ne]: deviceHistoryEntry.location.lng } + ), + models.sequelize.where( + Sequelize.fn("ST_Y", Sequelize.col("location")), + { [Op.ne]: deviceHistoryEntry.location.lat } + ), + ] as any, + order: [["fromDateTime", "ASC"]], + }); + const payload: { fromDateTime: Date; untilDateTime?: Date } = { + fromDateTime: fromTime, + }; + if (laterDeviceHistoryEntry) { + payload.untilDateTime = laterDeviceHistoryEntry.fromDateTime; + } + return successResponse( + response, + "Reference image exists at supplied time", + payload + ); + } else { + // Get reference image for device at time if any, and return it + const time = fromTime + ?.toISOString() + .replace(/:/g, "_") + .replace(".", "_"); + const kind = request.query.type || "pov"; + const mimeType = + kind === "pov" + ? deviceHistoryEntry?.settings?.referenceImagePOVMimeType + : deviceHistoryEntry?.settings?.referenceImageInSituMimeType; + + const validatedMimeType = + mimeType && ALLOWED_MIME_TYPES.includes(mimeType as any) + ? mimeType + : "image/webp"; + + const filename = `device-${ + device.id + }-reference-image@${time}.${getExtension(validatedMimeType)}`; + // Get reference image for device at time if any. + return streamS3Object( + request, + response, + referenceImage, + filename, + validatedMimeType, + response.locals.requestUser.id, + device.GroupId, + referenceImageFileSize + ); + } + } + return next( + new UnprocessableError( + "No reference image available for device at time" + ) + ); + } else { + return next( + new UnprocessableError( + `Reference images not supported for ${device.kind} devices.` + ) + ); + } } ); @@ -1204,12 +1238,25 @@ export default function (app: Application, baseUrl: string) { validateFields([ idOf(param("id")), query("view-mode").optional().equals("user"), - query("at-time").default(new Date().toISOString()).isISO8601().toDate(), + query("at-time").default(new Date()), query("type").optional().isIn(["pov", "in-situ"]), booleanOf(query("only-active"), false), ]), fetchAuthorizedRequiredDeviceById(param("id")), - async (request: Request, response: Response) => { + async (request: Request, response: Response, next: NextFunction) => { + let contentType = request.get("Content-Type"); + if (!ALLOWED_MIME_TYPES.includes(contentType as any)) { + contentType = "image/webp"; + } + if (!contentType) { + return next( + new FatalError( + `Unsupported image type. Allowed types: ${ALLOWED_MIME_TYPES.join( + ", " + )}` + ) + ); + } // Set the reference image. // If the location hasn't changed, we need to carry this forward whenever we create // another device history entry? @@ -1222,15 +1269,13 @@ export default function (app: Application, baseUrl: string) { DeviceId: device.id, GroupId: device.GroupId, location: { [Op.ne]: null }, - fromDateTime: { [Op.lt]: atTime }, }, order: [["fromDateTime", "DESC"]], }); if (!previousDeviceHistoryEntry) { // We can't add an image, because we don't have a device location. - return successResponse( - response, - "No location for device to tag with reference" + return next( + new UnprocessableError("No location for device to tag with reference") ); } @@ -1241,16 +1286,21 @@ export default function (app: Application, baseUrl: string) { !!previousSettings.referenceImagePOV || !!previousSettings.referenceImageInSitu; + // Upload with validated content type const { key, size } = await uploadFileStream(request as any, "ref"); + + // Store MIME type in settings const newSettings = referenceType === "pov" ? { - referenceImagePOVFileSize: size, referenceImagePOV: key, + referenceImagePOVFileSize: size, + referenceImagePOVMimeType: contentType, } : { - referenceImageInSituFileSize: size, referenceImageInSitu: key, + referenceImageInSituFileSize: size, + referenceImageInSituMimeType: contentType, }; if (hadPreviousReferenceImage) { @@ -1285,6 +1335,92 @@ export default function (app: Application, baseUrl: string) { } ); + /** + * @api {delete} /api/v1/devices/:deviceId/reference-image Delete reference image + * @apiName DeleteDeviceReferenceImage + * @apiGroup Device + * @apiParam {Integer} deviceId ID of the device + * @apiQuery {String} [at-time] ISO8601 date for which reference image should be deleted + * @apiQuery {String} [type] Image type ('pov' or 'in-situ') + * + * @apiDescription Deletes the reference image for a device at a specific time. + * If no time specified, deletes the current reference image. + * + * @apiUse V1UserAuthorizationHeader + * @apiUse V1ResponseSuccess + * @apiUse V1ResponseError + */ + app.delete( + `${apiUrl}/:id/reference-image`, + extractJwtAuthorizedUser, + validateFields([ + idOf(param("id")), + query("at-time").optional().isISO8601().toDate(), + query("type").optional().isIn(["pov", "in-situ"]), + ]), + fetchAuthorizedRequiredDeviceById(param("id")), + async (request: Request, response: Response, next: NextFunction) => { + try { + const atTime = + (request.query["at-time"] as unknown as Date) ?? new Date(); + const referenceType = request.query.type ?? "pov"; + const device = response.locals.device as Device; + + // Find relevant device history entry + const deviceHistoryEntry = await models.DeviceHistory.findOne({ + where: { + DeviceId: device.id, + GroupId: device.GroupId, + fromDateTime: { [Op.lte]: atTime }, + }, + order: [["fromDateTime", "DESC"]], + }); + + if (!deviceHistoryEntry) { + return successResponse(response, "No reference to delete"); + } + + const settings = deviceHistoryEntry.settings || {}; + const imageKey = + referenceType === "pov" + ? settings.referenceImagePOV + : settings.referenceImageInSitu; + + if (!imageKey) { + return successResponse(response, "No reference image to delete"); + } + + // Delete from S3 + await deleteFile(imageKey); + + // Update device history entry + const updatedSettings = { ...settings }; + if (referenceType === "pov") { + delete updatedSettings.referenceImagePOV; + delete updatedSettings.referenceImagePOVFileSize; + delete updatedSettings.referenceImagePOVMimeType; + } else { + delete updatedSettings.referenceImageInSitu; + delete updatedSettings.referenceImageInSituFileSize; + delete updatedSettings.referenceImageInSituMimeType; + } + + await models.DeviceHistory.create({ + ...deviceHistoryEntry.get({ plain: true }), + settings: updatedSettings, + fromDateTime: new Date(), + }); + + return successResponse( + response, + "Reference image deleted successfully" + ); + } catch (error) { + next(new FatalError("Failed to delete reference image")); + } + } + ); + /** * @api {post} /api/v1/devices/:deviceId/mask-regions Set mask regions for a device * @apiName SetDeviceMaskRegions @@ -1299,7 +1435,6 @@ export default function (app: Application, baseUrl: string) { * @apiSuccess {String} message Success message * @apiUse V1ResponseError */ - app.post( `${apiUrl}/:id/mask-regions`, extractJwtAuthorizedUser, @@ -1515,7 +1650,7 @@ export default function (app: Application, baseUrl: string) { * @apiGroup Device * @apiParam {Integer} deviceId Id of the device * - * @apiDescription Updates settings in the DeviceHistory table for a specified device. + * @apiDescription Updates settings, location, and device type in the DeviceHistory and Device tables for a specified device. * * @apiUse V1UserAuthorizationHeader * @@ -1528,30 +1663,127 @@ export default function (app: Application, baseUrl: string) { extractJwtAuthorizedUserOrDevice, validateFields([ idOf(param("id")), - body("settings").custom(jsonSchemaOf(ApiDeviceHistorySettingsSchema)), + body("settings") + .optional() + .custom(jsonSchemaOf(ApiDeviceHistorySettingsSchema)), + body("location") + .optional() + .isObject() + .withMessage("Location must be an object with lat and lng"), + body("location.lat") + .optional() + .isFloat({ min: -90, max: 90 }) + .withMessage("Latitude must be a valid number"), + body("location.lng") + .optional() + .isFloat({ min: -180, max: 180 }) + .withMessage("Longitude must be a valid number"), + body("type") + .optional() + .isIn(Object.values(DeviceType)) + .withMessage("Invalid device type"), booleanOf(query("only-active"), false), ]), fetchAuthorizedRequiredDeviceById(param("id")), async (request: Request, response: Response, next: NextFunction) => { try { const device = response.locals.device as Device; - const newSettings: ApiDeviceHistorySettings = request.body.settings; + const newSettings: ApiDeviceHistorySettings | undefined = + request.body.settings; + const newLocation = request.body.location; + const newKind = request.body.type; const setBy = response.locals.requestUser?.id ? "user" : "automatic"; - const updatedEntry = await models.DeviceHistory.updateDeviceSettings( - device.id, - device.GroupId, - newSettings, - setBy - ); - return successResponse( - response, - "Device settings updated successfully", - { - settings: updatedEntry, - } - ); + + // Update device location and create DeviceHistory entry if new location is provided + if (newLocation) { + device.location = newLocation; + await device.save(); + + const station = await tryToMatchLocationToStationInGroup( + models, + newLocation, + device.GroupId, + new Date() + ); + + await models.DeviceHistory.create({ + DeviceId: device.id, + GroupId: device.GroupId, + location: newLocation, + fromDateTime: new Date(), + setBy: setBy, + deviceName: device.deviceName, + saltId: device.saltId, + uuid: device.uuid, + stationId: station?.id, + }); + } + + // Update device type (kind) if provided + if (newKind && device.kind !== newKind) { + device.kind = newKind; + await device.save(); + } + + // Update device settings if provided + let updatedEntry; + if (newSettings) { + updatedEntry = await models.DeviceHistory.updateDeviceSettings( + device.id, + device.GroupId, + newSettings, + setBy + ); + } else { + // Fetch the latest settings entry if no new settings are provided + updatedEntry = await models.DeviceHistory.latest( + device.id, + device.GroupId + ); + } + + return successResponse(response, "Device updated successfully", { + settings: updatedEntry, + ...(newLocation && { location: newLocation }), + ...(newKind && { kind: newKind }), + }); + } catch (e) { + return next(new FatalError(`Failed to update device1: ${e.message}`)); + } + } + ); + + /** + * @api {get} /api/v1/devices/:deviceId/type Get device type + * @apiName GetDeviceType + * @apiGroup Device + * @apiParam {Integer} deviceId Id of the device + * + * @apiDescription Get the type of device + * + * @apiUse V1UserAuthorizationHeader + * + * @apiUse V1ResponseSuccess + * @apiInterface {apiSuccess::ApiDeviceTypeResponseSuccess} + * @apiUse V1ResponseError + */ + app.get( + `${apiUrl}/:id/type`, + extractJwtAuthorizedUserOrDevice, + validateFields([idOf(param("id"))]), + async (request: Request, response: Response, next: NextFunction) => { + try { + const device = await models.Device.findByPk(request.params.id); + if (!device) return next(new UnprocessableError("Device not found")); + + // Add logic to detect device type from device properties + const detectedType = device.kind; + + return successResponse(response, "Device type retrieved", { + type: detectedType, + }); } catch (e) { - return next(new FatalError("Failed to update device settings.")); + return; } } ); diff --git a/api/api/V1/recordingUtil.ts b/api/api/V1/recordingUtil.ts index 1d9b0ea1..1ff3509f 100755 --- a/api/api/V1/recordingUtil.ts +++ b/api/api/V1/recordingUtil.ts @@ -683,6 +683,8 @@ export const maybeUpdateDeviceHistory = async ( delete settings.referenceImageInSitu; delete settings.referenceImagePOVFileSize; delete settings.referenceImageInSituFileSize; + delete settings.referenceImagePOVMimeType; + delete settings.referenceImageInSituMimeType; delete settings.ratThresh; delete settings.maskRegions; delete settings.warp; diff --git a/types/api/device.d.ts b/types/api/device.d.ts index 1bea3614..d22aabe6 100644 --- a/types/api/device.d.ts +++ b/types/api/device.d.ts @@ -80,8 +80,10 @@ export type WindowsSettings = { export interface ApiDeviceHistorySettings { referenceImagePOV?: string; // S3 Key for a device reference image referenceImagePOVFileSize?: number; + referenceImagePOVMimeType?: string; referenceImageInSitu?: string; // S3 Key for a device reference image referenceImageInSituFileSize?: number; + referenceImageInSituMimeType?: string; warp?: { dimensions?: { width: number; height: number }; origin: [number, number]; From b32a974b8a63b73826d847d0788888be7e3807c1 Mon Sep 17 00:00:00 2001 From: Patrick Baxter Date: Wed, 29 Jan 2025 14:37:36 +1300 Subject: [PATCH 02/14] fix lint --- api/api/V1/Device.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/api/api/V1/Device.ts b/api/api/V1/Device.ts index 3d6d9b2b..7b587a56 100755 --- a/api/api/V1/Device.ts +++ b/api/api/V1/Device.ts @@ -1774,7 +1774,9 @@ export default function (app: Application, baseUrl: string) { async (request: Request, response: Response, next: NextFunction) => { try { const device = await models.Device.findByPk(request.params.id); - if (!device) return next(new UnprocessableError("Device not found")); + if (!device) { + return next(new UnprocessableError("Device not found")); + } // Add logic to detect device type from device properties const detectedType = device.kind; From f56d2d09cd1d97d858b16ba52c2302446d98ad9d Mon Sep 17 00:00:00 2001 From: Patrick Baxter Date: Tue, 4 Feb 2025 14:50:28 +1300 Subject: [PATCH 03/14] fix optional params --- browse-next/src/api/Device.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/browse-next/src/api/Device.ts b/browse-next/src/api/Device.ts index d2274566..dcdfbbac 100644 --- a/browse-next/src/api/Device.ts +++ b/browse-next/src/api/Device.ts @@ -601,7 +601,7 @@ export const getReferenceImageForDeviceAtTime = ( params.append("only-active", true.toString()); } return CacophonyApi.get( - `/api/v1/devices/${deviceId}/reference-image?${optionalQueryString(params)}` + `/api/v1/devices/${deviceId}/reference-image${optionalQueryString(params)}` ) as Promise>; }; @@ -617,7 +617,7 @@ export const hasReferenceImageForDeviceAtTime = ( } // Set the reference image for the location start time? Or create a new entry for this reference image starting now? return CacophonyApi.get( - `/api/v1/devices/${deviceId}/reference-image/exists?${optionalQueryString( + `/api/v1/devices/${deviceId}/reference-image/exists${optionalQueryString( params )}` ) as Promise< From 7bdd5e8f711b6bfe65213f0818d169e66a889150 Mon Sep 17 00:00:00 2001 From: Patrick Baxter Date: Wed, 5 Feb 2025 15:34:56 +1300 Subject: [PATCH 04/14] add get station devices endpoint, device reference photos, edit pov images --- api/api/V1/Device.ts | 3 +- api/api/V1/Group.ts | 2 +- api/api/V1/Station.ts | 76 +++ .../fileUploaders/uploadGenericRecording.ts | 34 ++ .../components/DeviceSetupReferencePhoto.vue | 519 ++++++++++++------ browse/src/api/Device.api.ts | 76 +++ browse/src/api/Station.api.ts | 9 + .../components/StationReferencePhotosTab.vue | 333 +++++++---- 8 files changed, 792 insertions(+), 260 deletions(-) diff --git a/api/api/V1/Device.ts b/api/api/V1/Device.ts index 7b587a56..94a95582 100755 --- a/api/api/V1/Device.ts +++ b/api/api/V1/Device.ts @@ -444,7 +444,8 @@ export default function (app: Application, baseUrl: string) { deprecatedField(query("where")), // Sidekick anyOf( query("onlyActive").optional().isBoolean().toBoolean(), - query("only-active").optional().isBoolean().toBoolean() + query("only-active").optional().isBoolean().toBoolean(), + query("stationId").optional().isInt().toInt() ), ]), fetchAuthorizedRequiredDevices, diff --git a/api/api/V1/Group.ts b/api/api/V1/Group.ts index 1fccf936..b4c18c4f 100755 --- a/api/api/V1/Group.ts +++ b/api/api/V1/Group.ts @@ -852,7 +852,7 @@ export default function (app: Application, baseUrl: string) { ); /** - * @api {get} /api/v1/groups/:groupIdOrName/station Add a single station. + * @api {post} /api/v1/groups/:groupIdOrName/station Add a single station. * @apiName CreateStation * @apiGroup Station * @apiDescription Create a single station diff --git a/api/api/V1/Station.ts b/api/api/V1/Station.ts index b129a3d8..28adc314 100755 --- a/api/api/V1/Station.ts +++ b/api/api/V1/Station.ts @@ -34,6 +34,8 @@ import { MIN_STATION_SEPARATION_METERS, } from "@models/util/locationUtils.js"; import { Op, QueryTypes } from "sequelize"; +import { mapDeviceResponse } from "./Device.js"; +import { Device } from "@/models/Device.js"; const models = await modelsInit(); @@ -740,4 +742,78 @@ export default function (app: Application, baseUrl: string) { return successResponse(response, { speciesCountBulk }); } ); + + /** + * @api {get} /api/v1/stations/:stationId/devices List devices currently assigned to a station + * @apiName GetDevicesForStation + * @apiGroup Station + * + * @apiDescription Returns all devices whose most recent DeviceHistory entry (before now) + * has `stationId === stationId`. In other words, they are currently located at this station. + * + * @apiParam {Number} stationId ID of the station + * @apiQuery {Boolean} [only-active=true] If `true`, only return active devices + * @apiUse V1UserAuthorizationHeader + * + * @apiUse V1ResponseSuccess + * @apiSuccess {Object[]} devices Array of devices currently assigned + * @apiUse V1ResponseError + */ + app.get( + `${apiUrl}/:stationId/devices`, + extractJwtAuthorizedUser, + validateFields([ + idOf(param("stationId")), + booleanOf(param("only-active")).default(true), + ]), + fetchAuthorizedRequiredStationById(param("stationId")), + async (req: Request, res: Response) => { + const station = res.locals.station; + const onlyActive = req.query["only-active"] !== "false"; + + // We only want devices in the same group as `station.GroupId`. + // We'll do a single raw query that: + // 1) finds all devices for that group, + // 2) looks up each device’s latest deviceHistory entry, + // 3) checks if stationId == :stationId + + const sql = ` + SELECT d.* + FROM "Devices" d + JOIN LATERAL ( + SELECT "stationId" + FROM "DeviceHistory" dh + WHERE dh."DeviceId" = d."id" + AND dh."GroupId" = d."GroupId" + AND dh."location" IS NOT NULL + AND dh."fromDateTime" <= now() + ORDER BY dh."fromDateTime" DESC + LIMIT 1 + ) latest ON true + WHERE d."GroupId" = :groupId + ${onlyActive ? `AND d."active" = true` : ""} + AND latest."stationId" = :stationId + `; + + const devicesRaw = await models.sequelize.query(sql, { + replacements: { + stationId: station.id, + groupId: station.GroupId, + }, + type: QueryTypes.SELECT, + mapToModel: true, + // mapToModel requires we pass the model: Device + model: models.Device, + }); + + // Now `devicesRaw` is an array of Device instances + // We can map them to the standard ApiDeviceResponse format: + const viewAsSuperUser = res.locals.viewAsSuperUser; + const devices = (devicesRaw as Device[]).map((dev) => + mapDeviceResponse(dev, viewAsSuperUser) + ); + + return successResponse(res, "Got devices for station", { devices }); + } + ); } diff --git a/api/api/fileUploaders/uploadGenericRecording.ts b/api/api/fileUploaders/uploadGenericRecording.ts index d5dba9fb..05f81c5f 100644 --- a/api/api/fileUploaders/uploadGenericRecording.ts +++ b/api/api/fileUploaders/uploadGenericRecording.ts @@ -439,6 +439,29 @@ export const uploadGenericRecording = })); } + if (!recordingDevice) { + return next( + new UnprocessableError( + `No device found for ID ${recordingDeviceId}. Cannot proceed with upload.` + ) + ); + } + if (!recordingDevice.GroupId) { + return next( + new UnprocessableError( + `Device ${recordingDeviceId} is not assigned to any group. Cannot upload a recording.` + ) + ); + } + if (!recordingDevice.Group) { + // If we rely on `recordingDevice.Group` from `include: [models.Group]` + return next( + new UnprocessableError( + `Device ${recordingDeviceId} has GroupId = ${recordingDevice.GroupId}, but no matching group found.` + ) + ); + } + if (response.locals.requestUser) { uploadingUser = response.locals.requestUser; } @@ -626,6 +649,17 @@ export const uploadGenericRecording = recordingTemplate.recordingDateTime, recordingTemplate.location ); + + if (!deviceId || !groupId) { + // We can throw a 422 or similar + await deleteUploads(uploadResults); + return next( + new UnprocessableError( + `Unable to determine valid device (${deviceId}) or group (${groupId}) for this recording.` + ) + ); + } + recordingTemplate.DeviceId = deviceId; recordingTemplate.GroupId = groupId; diff --git a/browse-next/src/components/DeviceSetupReferencePhoto.vue b/browse-next/src/components/DeviceSetupReferencePhoto.vue index 7513d445..0f6dae09 100644 --- a/browse-next/src/components/DeviceSetupReferencePhoto.vue +++ b/browse-next/src/components/DeviceSetupReferencePhoto.vue @@ -29,12 +29,11 @@ const deviceId = Number(route.params.deviceId) as DeviceId; const device = computed(() => { return ( (devices.value && - devices.value.find( - (device: ApiDeviceResponse) => device.id === deviceId - )) || + devices.value.find((d: ApiDeviceResponse) => d.id === deviceId)) || null ); }); + const referenceImage = ref(null); const referenceImageSkew = ref(); const singleFrameCanvas = ref(); @@ -52,23 +51,41 @@ const loading = computed(() => { }); const { width: singleFrameCanvasWidth } = useElementSize(singleFrameCanvas); + +// Used to replace (remove) the existing reference image const replaceExistingReferenceImage = async () => { latestReferenceImageURL.value = null; }; +const editingReferenceImage = ref(false); + +const editExistingReferenceImage = async () => { + if ( + latestReferenceImageURL.value && + typeof latestReferenceImageURL.value === "string" + ) { + try { + const resp = await fetch(latestReferenceImageURL.value); + const blob = await resp.blob(); + referenceImage.value = await createImageBitmap(blob); + editingReferenceImage.value = true; + } catch (e) { + console.error("Failed to load existing reference image to edit:", e); + } + } +}; + const onSelectReferenceImage = async (event: Event) => { if (event && event.target && (event.target as HTMLInputElement).files) { const files = (event.target as HTMLInputElement).files as FileList; const file = files[0]; - const hasReferenceImage = referenceImage.value !== null; referenceImage.value = await createImageBitmap(file); - if (hasReferenceImage) { - positionHandles(); - renderSkewedImage(); - } + positionHandles(); + renderSkewedImage(); } }; +// ----- Handle corner dragging logic ----- const handle0 = ref(); const handle1 = ref(); const handle2 = ref(); @@ -76,8 +93,8 @@ const handle3 = ref(); const selectedHandle = ref(null); let grabOffsetX = 0; -let revealGrabOffsetX = 0; let grabOffsetY = 0; + const moveHandle = (event: PointerEvent) => { const handle = event.currentTarget as HTMLDivElement; if (selectedHandle.value === handle) { @@ -89,6 +106,8 @@ const moveHandle = (event: PointerEvent) => { } }; +const singleFrame = ref(); + const constrainHandle = ( handle: HTMLDivElement, clientX?: number, @@ -99,18 +118,16 @@ const constrainHandle = ( left: handleX, top: handleY, } = handle.getBoundingClientRect(); - if (clientX === undefined) { - clientX = handleX; - } - if (clientY === undefined) { - clientY = handleY; - } + if (clientX === undefined) clientX = handleX; + if (clientY === undefined) clientY = handleY; + const { left: parentX, top: parentY, width, height, } = (handle.parentElement as HTMLDivElement).getBoundingClientRect(); + let x = Math.min( width - handleW, Math.max(0, clientX - parentX - grabOffsetX) @@ -120,8 +137,10 @@ const constrainHandle = ( Math.max(0, clientY - parentY - grabOffsetY) ); const dim = handleW / 2; + if (singleFrame.value) { const singleFrameBounds = singleFrame.value.getBoundingClientRect(); + // Logic to constrain each corner to the corners of the singleFrame if (handle === handle0.value) { x = Math.min(singleFrameBounds.left - (parentX + dim), x); y = Math.min(singleFrameBounds.top - (parentY + dim), y); @@ -136,42 +155,97 @@ const constrainHandle = ( y = Math.max(singleFrameBounds.bottom - (parentY + dim), y); } } + handle.style.left = `${x}px`; handle.style.top = `${y}px`; }; -const singleFrame = ref(); +const buffer = 0; // extra offset in pixels to keep handles away from the exact corner + const positionHandles = () => { + // Ensure that our essential elements exist if ( - singleFrame.value && - handle0.value && - handle1.value && - handle2.value && - handle3.value && - referenceImage.value + !handle0.value || + !handle1.value || + !handle2.value || + !handle3.value || + !referenceImage.value ) { - const singleFrameBounds = singleFrame.value.getBoundingClientRect(); + return; + } + + const handleBounds = handle0.value.getBoundingClientRect(); + const dim = handleBounds.width / 2; // half the handle width + + if ( + singleFrameBounds.top || + singleFrameBounds.left || + singleFrameBounds.right || + singleFrameBounds.bottom + ) { + // When singleFrame is available, use its bounds to position handles with buffer adjustments. const { left: parentX, top: parentY } = ( handle0.value.parentElement as HTMLDivElement ).getBoundingClientRect(); - const handleBounds = ( - handle0.value as HTMLDivElement - ).getBoundingClientRect(); - const dim = handleBounds.width / 2; - handle0.value.style.left = `${singleFrameBounds.left - (parentX + dim)}px`; - handle0.value.style.top = `${singleFrameBounds.top - (parentY + dim)}px`; - - handle1.value.style.left = `${singleFrameBounds.right - (parentX + dim)}px`; - handle1.value.style.top = `${singleFrameBounds.top - (parentY + dim)}px`; - handle2.value.style.left = `${singleFrameBounds.right - (parentX + dim)}px`; - handle2.value.style.top = `${singleFrameBounds.bottom - (parentY + dim)}px`; - - handle3.value.style.left = `${singleFrameBounds.left - (parentX + dim)}px`; - handle3.value.style.top = `${singleFrameBounds.bottom - (parentY + dim)}px`; + // For the top-left handle: push the center inside by the buffer. + handle0.value.style.left = `${ + singleFrameBounds.left - parentX - dim + buffer + }px`; + handle0.value.style.top = `${ + singleFrameBounds.top - parentY - dim + buffer + }px`; + + // For the top-right handle: move it left by the buffer. + handle1.value.style.left = `${ + singleFrameBounds.right - parentX - dim - buffer + }px`; + handle1.value.style.top = `${ + singleFrameBounds.top - parentY - dim + buffer + }px`; + + // For the bottom-right handle: move it left and up by the buffer. + handle2.value.style.left = `${ + singleFrameBounds.right - parentX - dim - buffer + }px`; + handle2.value.style.top = `${ + singleFrameBounds.bottom - parentY - dim - buffer + }px`; + + // For the bottom-left handle: move it right by the buffer. + handle3.value.style.left = `${ + singleFrameBounds.left - parentX - dim + buffer + }px`; + handle3.value.style.top = `${ + singleFrameBounds.bottom - parentY - dim - buffer + }px`; + } else { + // Fallback: if there is no singleFrame, position the handles based on the container dimensions. + const container = skewContainer.value; + if (!container) return; + const { width: cWidth, height: cHeight } = + container.getBoundingClientRect(); + + // Top-left handle inset by the buffer. + handle0.value.style.left = `${0 - dim + buffer}px`; + handle0.value.style.top = `${0 - dim + buffer}px`; + + // Top-right handle inset by the buffer. + handle1.value.style.left = `${cWidth - dim - buffer}px`; + handle1.value.style.top = `${0 - dim + buffer}px`; + + // Bottom-right handle inset by the buffer. + handle2.value.style.left = `${cWidth - dim - buffer}px`; + handle2.value.style.top = `${cHeight - dim - buffer}px`; + + // Bottom-left handle inset by the buffer. + handle3.value.style.left = `${0 - dim + buffer}px`; + handle3.value.style.top = `${cHeight - dim - buffer}px`; } + renderSkewedImage(); }; + watch(referenceImageSkew, positionHandles); const renderSkewedImage = () => { @@ -184,10 +258,13 @@ const renderSkewedImage = () => { handle3.value && referenceImage.value ) { + ctx.clearRect(0, 0, ctx.canvas.width, ctx.canvas.height); ctx.save(); ctx.globalAlpha = savingReferenceImage.value ? 1 : parseFloat(overlayOpacity.value); + + // drawSkewedImage applies the 4-handle corners to the reference image drawSkewedImage( ctx, [handle0.value, handle1.value, handle2.value, handle3.value], @@ -195,6 +272,7 @@ const renderSkewedImage = () => { ); ctx.restore(); + // If not saving, draw an outline of the thermal camera's single-frame region if (singleFrame.value && !savingReferenceImage.value) { ctx.save(); const { @@ -203,14 +281,13 @@ const renderSkewedImage = () => { top: parentY, } = ctx.canvas.getBoundingClientRect(); const ratio = ctx.canvas.width / canvasOnScreenWidth; - const singleFrameBounds = ( - singleFrame.value as HTMLCanvasElement - ).getBoundingClientRect(); - // Now draw the outline of the underlying canvas on top: + const singleFrameBounds = singleFrame.value.getBoundingClientRect(); + ctx.lineWidth = 1; ctx.strokeStyle = "white"; ctx.globalCompositeOperation = "color-dodge"; ctx.scale(ratio, ratio); + ctx.strokeRect( singleFrameBounds.left - parentX, singleFrameBounds.top - parentY, @@ -218,6 +295,8 @@ const renderSkewedImage = () => { singleFrameBounds.height ); ctx.restore(); + + // Darken everything outside the singleFrame bounds ctx.save(); ctx.fillStyle = "rgba(0, 0, 0, 0.5)"; ctx.fillRect( @@ -233,16 +312,16 @@ const renderSkewedImage = () => { singleFrameBounds.height * ratio ); ctx.fillRect( - ctx.canvas.width - (singleFrameBounds.left - parentX) * ratio, + (singleFrameBounds.right - parentX) * ratio, (singleFrameBounds.top - parentY) * ratio, - ctx.canvas.width - (singleFrameBounds.left - parentX) * ratio, + ctx.canvas.width - (singleFrameBounds.right - parentX) * ratio, singleFrameBounds.height * ratio ); ctx.fillRect( 0, - (singleFrameBounds.top - parentY + singleFrameBounds.height) * ratio, + (singleFrameBounds.bottom - parentY) * ratio, ctx.canvas.width, - ctx.canvas.height - (singleFrameBounds.top - parentY) * ratio + ctx.canvas.height - (singleFrameBounds.bottom - parentY) * ratio ); ctx.restore(); } @@ -251,21 +330,23 @@ const renderSkewedImage = () => { watch(overlayOpacity, renderSkewedImage); watch(singleFrameCanvasWidth, () => { - // When the cptv single frame is scaled small, and the handle constraints are close around it, - // if we scale it large again we need to re-evaluate the handle constraints. + // Re-check handle constraints if the singleFrame has changed in size if (singleFrame.value && handle0.value) { const singleFrameBounds = singleFrame.value.getBoundingClientRect(); const singleFrameParentBounds = ( (singleFrame.value.parentElement as HTMLDivElement) .parentElement as HTMLDivElement ).getBoundingClientRect(); + const sfLeft = singleFrameBounds.left - singleFrameParentBounds.left; const sfTop = singleFrameBounds.top - singleFrameParentBounds.top; - const sfRight = sfLeft + singleFrameBounds.width; //singleFrameBounds.right - singleFrameParentBounds.right; - const sfBottom = sfTop + singleFrameBounds.height; //singleFrameBounds.bottom - singleFrameParentBounds.bottom; + const sfRight = sfLeft + singleFrameBounds.width; + const sfBottom = sfTop + singleFrameBounds.height; + const parentBounds = ( handle0.value.parentElement as HTMLDivElement ).getBoundingClientRect(); + for (const handle of [ handle0.value, handle1.value, @@ -277,6 +358,7 @@ watch(singleFrameCanvasWidth, () => { const dim = width / 2; let x = handleX - parentBounds.left; let y = handleY - parentBounds.top; + if (h === handle0.value) { x = Math.min(x, sfLeft - dim); y = Math.min(y, sfTop - dim); @@ -300,7 +382,7 @@ watch(cptvFrameScale, renderSkewedImage); const referenceImageIsLandscape = computed(() => { if (referenceImage.value) { - return referenceImage.value?.width >= referenceImage.value?.height; + return referenceImage.value.width >= referenceImage.value.height; } return true; }); @@ -322,7 +404,6 @@ const cptvFrameHeight = computed(() => { }); const grabHandle = (event: PointerEvent) => { - // NOTE: Maintain the offset of the cursor on the pointer when it's selected. grabOffsetX = event.offsetX; grabOffsetY = event.offsetY; const target = event.currentTarget as HTMLDivElement; @@ -331,14 +412,22 @@ const grabHandle = (event: PointerEvent) => { target.setPointerCapture(event.pointerId); }; +const releaseHandle = (event: PointerEvent) => { + const target = event.currentTarget as HTMLDivElement; + selectedHandle.value = null; + target.classList.remove("selected"); + target.releasePointerCapture(event.pointerId); +}; + +// ----- Reveal slider logic ----- +const revealSlider = ref(); +const revealHandle = ref(); const revealHandleSelected = ref(false); +let revealGrabOffsetX = 0; + const grabRevealHandle = (event: PointerEvent) => { - // @pointerup="releaseRevealHandle" - // @pointermove="moveRevealHandle" window.addEventListener("pointermove", moveRevealHandle); window.addEventListener("pointerup", releaseRevealHandle); - - // NOTE: Maintain the offset of the cursor on the pointer when it's selected. revealGrabOffsetX = event.offsetX; const target = event.currentTarget as HTMLDivElement; target.classList.add("selected"); @@ -346,31 +435,11 @@ const grabRevealHandle = (event: PointerEvent) => { target.setPointerCapture(event.pointerId); }; -const releaseHandle = (event: PointerEvent) => { - const target = event.currentTarget as HTMLDivElement; - selectedHandle.value = null; - target.classList.remove("selected"); - target.releasePointerCapture(event.pointerId); -}; - -const releaseRevealHandle = (event: PointerEvent) => { - if (revealHandleSelected.value && revealHandle.value) { - window.removeEventListener("pointermove", moveRevealHandle); - window.removeEventListener("pointerup", releaseRevealHandle); - const target = revealHandle.value; - target.classList.remove("selected"); - revealHandleSelected.value = false; - target.releasePointerCapture(event.pointerId); - } -}; - const moveRevealHandle = (event: PointerEvent) => { if (revealHandleSelected.value && revealHandle.value) { event.preventDefault(); const target = revealHandle.value; - const parentBounds = ( - target.parentElement as HTMLDivElement - ).getBoundingClientRect(); + const parentBounds = target.parentElement!.getBoundingClientRect(); const handleBounds = target.getBoundingClientRect(); const x = Math.min( Math.max( @@ -386,51 +455,71 @@ const moveRevealHandle = (event: PointerEvent) => { } }; +const releaseRevealHandle = (event: PointerEvent) => { + if (revealHandleSelected.value && revealHandle.value) { + window.removeEventListener("pointermove", moveRevealHandle); + window.removeEventListener("pointerup", releaseRevealHandle); + const target = revealHandle.value; + target.classList.remove("selected"); + revealHandleSelected.value = false; + target.releasePointerCapture(event.pointerId); + } +}; + +// ----- Saving reference image ----- const savingReferenceImage = ref(false); + const saveReferenceImage = async () => { const ctx = referenceImageSkew.value?.getContext("2d"); - if (ctx) { - savingReferenceImage.value = true; - renderSkewedImage(); - ctx.save(); - const { - width: canvasOnScreenWidth, - left: parentX, - top: parentY, - } = ctx.canvas.getBoundingClientRect(); - const ratio = ctx.canvas.width / canvasOnScreenWidth; - const singleFrameBounds = ( - singleFrame.value as HTMLCanvasElement - ).getBoundingClientRect(); - // Now draw the outline of the underlying canvas on top: - const imageData = ctx.getImageData( - (singleFrameBounds.left - parentX) * ratio, - (singleFrameBounds.top - parentY) * ratio, - singleFrameBounds.width * ratio, - singleFrameBounds.height * ratio - ); - ctx.restore(); + if (!ctx) return; + + savingReferenceImage.value = true; + renderSkewedImage(); // do one final draw at full opacity + ctx.save(); + const { + width: canvasOnScreenWidth, + left: parentX, + top: parentY, + } = ctx.canvas.getBoundingClientRect(); + const ratio = ctx.canvas.width / canvasOnScreenWidth; + const singleFrameBounds = singleFrame.value?.getBoundingClientRect(); + if (!singleFrameBounds) { savingReferenceImage.value = false; - renderSkewedImage(); + return; + } - const webp = await encode(imageData, { quality: 90 }); - const response = await updateReferenceImageForDeviceAtCurrentLocation( - device.value!.id, - webp - ); - if (response.success) { - emit("updated-reference-image"); - } + const imageData = ctx.getImageData( + (singleFrameBounds.left - parentX) * ratio, + (singleFrameBounds.top - parentY) * ratio, + singleFrameBounds.width * ratio, + singleFrameBounds.height * ratio + ); + ctx.restore(); + + savingReferenceImage.value = false; + renderSkewedImage(); + + const webp = await encode(imageData, { quality: 90 }); + const response = await updateReferenceImageForDeviceAtCurrentLocation( + device.value!.id, + webp + ); + if (response.success) { + // Refresh or notify success + emit("updated-reference-image"); + // Optionally switch out of editing mode if editing an existing image + editingReferenceImage.value = false; } }; -const revealSlider = ref(); -const revealHandle = ref(); + const helpInfo = ref(true); + + diff --git a/browse/src/api/Device.api.ts b/browse/src/api/Device.api.ts index dc2eb5f1..060ba1c6 100644 --- a/browse/src/api/Device.api.ts +++ b/browse/src/api/Device.api.ts @@ -457,7 +457,6 @@ function getReferenceImage( type = "pov", atTime, checkExists = false, - onlyActive = false, }: { type?: "pov" | "in-situ"; atTime?: Date; @@ -468,13 +467,11 @@ function getReferenceImage( const pathSuffix = checkExists ? "/exists" : ""; const params = new URLSearchParams(); - if (type) params.append("type", type); - if (atTime) params.append("at-time", atTime.toISOString()); - if (shouldViewAsSuperUser()) { - params.append("only-active", onlyActive ? "true" : "false"); - } else { - params.append("view-mode", "user"); - params.append("only-active", onlyActive ? "true" : "false"); + if (type) { + params.append("type", type); + } + if (atTime) { + params.append("at-time", atTime.toISOString()); } return CacophonyApi.getBinary( @@ -492,7 +489,6 @@ function deleteReferenceImage( { type = "pov", atTime, - onlyActive = false, }: { type?: "pov" | "in-situ"; atTime?: Date; @@ -500,14 +496,11 @@ function deleteReferenceImage( } ): Promise> { const params = new URLSearchParams(); - if (type) params.append("type", type); - if (atTime) params.append("at-time", atTime.toISOString()); - - if (shouldViewAsSuperUser()) { - params.append("only-active", onlyActive ? "true" : "false"); - } else { - params.append("view-mode", "user"); - params.append("only-active", onlyActive ? "true" : "false"); + if (type) { + params.append("type", type); + } + if (atTime) { + params.append("at-time", atTime.toISOString()); } return CacophonyApi.delete( diff --git a/browse/src/components/StationReferencePhotosTab.vue b/browse/src/components/StationReferencePhotosTab.vue index b5fea005..ccc41b67 100644 --- a/browse/src/components/StationReferencePhotosTab.vue +++ b/browse/src/components/StationReferencePhotosTab.vue @@ -107,13 +107,6 @@ height="auto" @click="openImageInModal(img.image)" /> - - Remove - {{ deviceLabel(img) }} @@ -130,14 +123,13 @@ import { isViewingAsOtherUser } from "@/components/NavBar.vue"; import { shouldViewAsSuperUser } from "@/utils"; import api from "@/api"; -// Example: deviceImages[] items interface DeviceImageItem { deviceId: number; refType: "pov" | "in-situ"; key: string; loading: boolean; - image: string | null; // URL blob - deviceName?: string; // optional, if you want to label them + image: string | null; + deviceName?: string; } interface StationImageItem { @@ -188,11 +180,11 @@ export default { // Fetch devices assigned to this station // (adapt this call to however you find devices for a station) const devicesRes = await api.station.listDevices(this.station.id); - if (!devicesRes.success) return; + if (!devicesRes.success) { + return; + } const devices = devicesRes.result.devices; - console.log("Devices found:", devices); - // 3) For each device, try "pov" + "in-situ" for (const dev of devices) { const refTypes = ["pov", "in-situ"] as const; for (const refType of refTypes) { @@ -202,7 +194,7 @@ export default { key: `${dev.id}-${refType}`, loading: true, image: null, - deviceName: dev.deviceName, // if you want to label it + deviceName: dev.deviceName, }; this.deviceImages.push(devImg); @@ -210,52 +202,43 @@ export default { const resp = await api.device.getReferenceImage(dev.id, { type: refType, }); - console.log("Device image response:", resp); if (resp.success) { const blob = resp.result as Blob; devImg.image = URL.createObjectURL(blob); } else { - console.log("Error fetching device image:", resp.error); // If no image for that type, remove it from the array this.deviceImages = this.deviceImages.filter((i) => i !== devImg); } } catch (err) { // If 404 or similar, remove the placeholder this.deviceImages = this.deviceImages.filter((i) => i !== devImg); - console.error("Caught Error fetching device image:", err); } devImg.loading = false; } } }, methods: { - // --- Station images --- - async deleteStationImage(fileKey: string) { - // Remove from UI this.stationImages = this.stationImages.filter( (img) => img.key !== fileKey ); - // Call station API to remove await api.station.deleteReferenceImage(this.station.id, fileKey); }, async uploadSelectedStationImage() { - if (!this.selectedStationUpload) return; + if (!this.selectedStationUpload) { + return; + } const file = this.selectedStationUpload; - // You can adapt your resizing logic here if you want - // or just pass the File to the server as-is: const resizedBlob = await this.resizeImage(file); - // Upload const resp = await api.station.uploadReferenceImage( this.station.id, resizedBlob ); if (resp.success) { const { fileKey } = resp.result; - // Add to our stationImages list so user sees it this.stationImages.push({ key: fileKey, loading: false, @@ -266,7 +249,6 @@ export default { this.selectedStationUpload = null; }, - // (Example) Resizing helper if you want it resizeImage(file: File): Promise { return new Promise((resolve) => { const reader = new FileReader(); @@ -294,25 +276,9 @@ export default { }); }, - // --- Device images --- - - async deleteDeviceImage(deviceId: number, refType: "pov" | "in-situ") { - // Remove from UI - this.deviceImages = this.deviceImages.filter( - (img) => !(img.deviceId === deviceId && img.refType === refType) - ); - // Call device API to remove - await api.device.deleteReferenceImage(deviceId, { type: refType }); - }, - - // Example device label helper deviceLabel(img: DeviceImageItem) { - // If you want to label them by device name & refType return `${img.deviceName || "Device #" + img.deviceId} - ${img.refType}`; }, - - // --- Shared modal --- - openImageInModal(image: string) { this.showModal = true; this.modalImage = image; diff --git a/browse/src/views/RecordingView.vue b/browse/src/views/RecordingView.vue index 85730405..7f57434b 100644 --- a/browse/src/views/RecordingView.vue +++ b/browse/src/views/RecordingView.vue @@ -43,7 +43,7 @@ > {{ dateString }}, {{ timeString }} @@ -135,6 +135,7 @@ export default { station: null, modalImage: null, showModal: false, + deviceImage: null, }; }, computed: { @@ -250,19 +251,29 @@ export default { methods: { async openReferenceImageInModal() { if (!this.modalImage) { - const imageItem = { - loading: true, - key: this.stationReferencePhoto, - image: null, - }; - this.modalImage = imageItem; - this.showModal = true; - api.station - .getReferenceImage(this.station.id, this.stationReferencePhoto) - .then((image) => { - imageItem.image = window.URL.createObjectURL(image.result as Blob); - imageItem.loading = false; - }); + if (this.deviceImage) { + this.modalImage = { + loading: false, + image: this.deviceImage, + }; + this.showModal = true; + } else if (this.stationReferencePhoto) { + const imageItem = { + loading: true, + key: this.stationReferencePhoto, + image: null, + }; + this.modalImage = imageItem; + this.showModal = true; + api.station + .getReferenceImage(this.station.id, this.stationReferencePhoto) + .then((image) => { + imageItem.image = window.URL.createObjectURL( + image.result as Blob + ); + imageItem.loading = false; + }); + } } else { this.showModal = true; } @@ -391,6 +402,24 @@ export default { }, mounted: async function () { await this.fetchRecording({ id: this.$route.params.id, action: "updated" }); + const atTime = new Date(this.recording.recordingDateTime); + const deviceImageExists = await api.device.getReferenceImage( + this.recording.deviceId, + { + type: "pov", + checkExists: true, + atTime, + } + ); + if (deviceImageExists.success) { + const res = await api.device.getReferenceImage(this.recording.deviceId, { + type: "pov", + atTime, + }); + if (res.success) { + this.deviceImage = window.URL.createObjectURL(res.result); + } + } }, }; From d3508f72e376b5e2c1e6e053ff73d37faea705c8 Mon Sep 17 00:00:00 2001 From: Patrick Baxter Date: Wed, 19 Feb 2025 10:35:53 +1300 Subject: [PATCH 10/14] lint fix --- api/api/V1/Station.ts | 2 +- browse-next/src/components/DeviceRecordingSetup.vue | 5 +++-- .../src/components/DeviceSetupReferencePhoto.vue | 12 +++++++++--- browse/src/api/Device.api.ts | 1 - browse/src/components/StationReferencePhotosTab.vue | 4 ++-- 5 files changed, 15 insertions(+), 9 deletions(-) diff --git a/api/api/V1/Station.ts b/api/api/V1/Station.ts index 28adc314..dc4d1cd3 100755 --- a/api/api/V1/Station.ts +++ b/api/api/V1/Station.ts @@ -35,7 +35,7 @@ import { } from "@models/util/locationUtils.js"; import { Op, QueryTypes } from "sequelize"; import { mapDeviceResponse } from "./Device.js"; -import { Device } from "@/models/Device.js"; +import type { Device } from "@/models/Device.js"; const models = await modelsInit(); diff --git a/browse-next/src/components/DeviceRecordingSetup.vue b/browse-next/src/components/DeviceRecordingSetup.vue index 2b2d57e0..71c618bf 100644 --- a/browse-next/src/components/DeviceRecordingSetup.vue +++ b/browse-next/src/components/DeviceRecordingSetup.vue @@ -335,8 +335,9 @@ function calculateTimePercentagePoints( startTime: string, endTime: string ): Array<{ left: string; width: string }> { - if (startTime === "12:00" && endTime === "12:00") - return [{ left: "0%", width: "100%" }]; + if (startTime === "12:00" && endTime === "12:00") { +return [{ left: "0%", width: "100%" }]; +} const startPercentage = timeToPercentage(startTime); const endPercentage = timeToPercentage(endTime); diff --git a/browse-next/src/components/DeviceSetupReferencePhoto.vue b/browse-next/src/components/DeviceSetupReferencePhoto.vue index 996e6509..04672f55 100644 --- a/browse-next/src/components/DeviceSetupReferencePhoto.vue +++ b/browse-next/src/components/DeviceSetupReferencePhoto.vue @@ -169,8 +169,12 @@ const constrainHandle = ( left: handleX, top: handleY, } = handle.getBoundingClientRect(); - if (clientX === undefined) clientX = handleX; - if (clientY === undefined) clientY = handleY; + if (clientX === undefined) { +clientX = handleX; +} + if (clientY === undefined) { +clientY = handleY; +} const { left: parentX, @@ -500,7 +504,9 @@ const savingReferenceImage = ref(false); const saveReferenceImage = async () => { const ctx = referenceImageSkew.value?.getContext("2d"); - if (!ctx) return; + if (!ctx) { +return; +} savingReferenceImage.value = true; renderSkewedImage(); // do one final draw at full opacity diff --git a/browse/src/api/Device.api.ts b/browse/src/api/Device.api.ts index 060ba1c6..324fee0c 100644 --- a/browse/src/api/Device.api.ts +++ b/browse/src/api/Device.api.ts @@ -288,7 +288,6 @@ async function updateDeviceSettings( deviceId: DeviceId, settings: ApiDeviceHistorySettings ): Promise> { - debugger; return CacophonyApi.post(`/api/v1/devices/${deviceId}/settings`, { settings, }); diff --git a/browse/src/components/StationReferencePhotosTab.vue b/browse/src/components/StationReferencePhotosTab.vue index ccc41b67..d470445b 100644 --- a/browse/src/components/StationReferencePhotosTab.vue +++ b/browse/src/components/StationReferencePhotosTab.vue @@ -18,7 +18,7 @@
Device Reference Photos
{ + if (referenceType === "pov") { + return !!dh.settings?.referenceImagePOV; + } else { + return !!dh.settings?.referenceImageInSitu; + } + }); + const settings = deviceHistoryEntry.settings || {}; const imageKey = referenceType === "pov" @@ -1589,27 +1606,27 @@ export default function (app: Application, baseUrl: string) { extractJwtAuthorizedUserOrDevice, validateFields([ idOf(param("id")), - query("at-time").default(new Date().toISOString()).isISO8601().toDate(), + query("at-time").optional().isISO8601().toDate(), booleanOf(query("only-active"), false), booleanOf(query("latest-synced"), false), ]), fetchAuthorizedRequiredDeviceById(param("id")), async (request: Request, response: Response, next: NextFunction) => { try { - const atTime = request.query["at-time"] as unknown as Date; + const atTime = + (request.query["at-time"] as unknown as Date) ?? new Date(); const device = response.locals.device as Device; + debugger; let deviceSettings: DeviceHistory | null = null; if (request.query["latest-synced"]) { - deviceSettings = await models.DeviceHistory.findOne({ - where: { - DeviceId: device.id, - GroupId: device.GroupId, - location: { [Op.ne]: null }, - fromDateTime: { [Op.lte]: atTime }, + deviceSettings = await models.DeviceHistory.latest( + device.id, + device.GroupId, + atTime, + { "settings.synced": true, - }, - order: [["fromDateTime", "DESC"]], - }); + } + ); } else { deviceSettings = await models.DeviceHistory.latest( device.id, diff --git a/api/models/DeviceHistory.ts b/api/models/DeviceHistory.ts index b72704f2..a89768c9 100644 --- a/api/models/DeviceHistory.ts +++ b/api/models/DeviceHistory.ts @@ -56,7 +56,8 @@ export interface DeviceHistoryStatic extends ModelStaticCommon { latest( deviceId: DeviceId, groupId: GroupId, - atTime?: Date + atTime?: Date, + where?: any ): Promise; getEarliestFromDateTimeForDeviceAtCurrentLocation( deviceId: DeviceId, @@ -134,31 +135,43 @@ export default function ( DeviceHistory.latest = async function ( deviceId: DeviceId, groupId: GroupId, - atTime = new Date() + atTime = new Date(), + where = {} ): Promise { - let deviceHistoryEntry = await this.findOne({ + // Find the closest entry before (or at) atTime + const before = await this.findOne({ + where: { + DeviceId: deviceId, + GroupId: groupId, + fromDateTime: { [Op.lte]: atTime }, + location: { [Op.ne]: null }, + ...where, + }, + order: [["fromDateTime", "DESC"]], + }); + + // Find the closest entry after (or at) atTime + const after = await this.findOne({ where: { DeviceId: deviceId, GroupId: groupId, fromDateTime: { [Op.gte]: atTime }, + location: { [Op.ne]: null }, + ...where, }, order: [["fromDateTime", "ASC"]], }); - // - // If none exists, fallback to the latest entry "before" atTime - // - if (!deviceHistoryEntry) { - deviceHistoryEntry = await this.findOne({ - where: { - DeviceId: deviceId, - GroupId: groupId, - fromDateTime: { [Op.lte]: atTime }, - }, - order: [["fromDateTime", "DESC"]], - }); + // If both exist, choose the one with the smallest difference to atTime. + if (before && after) { + const diffBefore = atTime.getTime() - before.fromDateTime.getTime(); + const diffAfter = after.fromDateTime.getTime() - atTime.getTime(); + + return diffBefore <= diffAfter ? before : after; } - return deviceHistoryEntry; + + // If only one exists, return that one. + return before || after; }; DeviceHistory.updateDeviceSettings = async function ( deviceId: DeviceId, From 18a1ab2da5bd04e2baf17fa16f5997b041878d5d Mon Sep 17 00:00:00 2001 From: Patrick Baxter Date: Wed, 19 Feb 2025 12:12:32 +1300 Subject: [PATCH 12/14] lint fix --- api/api/V1/Device.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/api/api/V1/Device.ts b/api/api/V1/Device.ts index d70db9f7..9cbef3fe 100755 --- a/api/api/V1/Device.ts +++ b/api/api/V1/Device.ts @@ -948,7 +948,7 @@ export default function (app: Application, baseUrl: string) { new Date(); const device = response.locals.device as Device; - let deviceHistoryEntry = await this.findOne({ + const deviceHistoryEntry = await this.findOne({ where: { DeviceId: device.id, GroupId: device.GroupId, From 041adcf752b8dad000c46ad3b05624983eb0fd72 Mon Sep 17 00:00:00 2001 From: Patrick Baxter Date: Wed, 19 Feb 2025 12:43:14 +1300 Subject: [PATCH 13/14] try gurantee image --- api/api/V1/Device.ts | 23 +++++++++++++---------- 1 file changed, 13 insertions(+), 10 deletions(-) diff --git a/api/api/V1/Device.ts b/api/api/V1/Device.ts index 9cbef3fe..d76b9177 100755 --- a/api/api/V1/Device.ts +++ b/api/api/V1/Device.ts @@ -947,15 +947,19 @@ export default function (app: Application, baseUrl: string) { (request.query["at-time"] as unknown as Date)) || new Date(); const device = response.locals.device as Device; - - const deviceHistoryEntry = await this.findOne({ - where: { - DeviceId: device.id, - GroupId: device.GroupId, - fromDateTime: { [Op.lte]: atTime }, - }, - order: [["fromDateTime", "ASC"]], - }); + const kind = (request.query.type as string) || "pov"; + const query = + kind === "pov" + ? "settings.referenceImagePOV" + : "settings.referenceImageInSitu"; + const deviceHistoryEntry = await models.DeviceHistory.latest( + device.id, + device.GroupId, + atTime, + { + [query]: { [Op.ne]: null }, + } + ); if (!deviceHistoryEntry) { return next( new UnprocessableError( @@ -1131,7 +1135,6 @@ export default function (app: Application, baseUrl: string) { } else if ( [DeviceType.Hybrid, DeviceType.Thermal].includes(device.kind) ) { - const kind = (request.query.type as string) || "pov"; let referenceImage; let referenceImageFileSize; From c99c3e3dee45b18b2fae13afc0d0fd5c76587969 Mon Sep 17 00:00:00 2001 From: Patrick Baxter Date: Wed, 19 Feb 2025 13:57:39 +1300 Subject: [PATCH 14/14] fix: device type and ensure settings --- api/api/V1/Device.ts | 6 ++- browse-next/src/api/Device.ts | 38 ++++-------------- .../src/components/DeviceRecordingSetup.vue | 39 ++++++++++++------- .../components/DeviceSetupReferencePhoto.vue | 13 +++---- types/api/consts.ts | 1 + 5 files changed, 44 insertions(+), 53 deletions(-) diff --git a/api/api/V1/Device.ts b/api/api/V1/Device.ts index d76b9177..a3497769 100755 --- a/api/api/V1/Device.ts +++ b/api/api/V1/Device.ts @@ -1619,7 +1619,6 @@ export default function (app: Application, baseUrl: string) { const atTime = (request.query["at-time"] as unknown as Date) ?? new Date(); const device = response.locals.device as Device; - debugger; let deviceSettings: DeviceHistory | null = null; if (request.query["latest-synced"]) { deviceSettings = await models.DeviceHistory.latest( @@ -1634,7 +1633,10 @@ export default function (app: Application, baseUrl: string) { deviceSettings = await models.DeviceHistory.latest( device.id, device.GroupId, - atTime + atTime, + { + "settings.synced": { [Op.ne]: null }, + } ); } if (deviceSettings) { diff --git a/browse-next/src/api/Device.ts b/browse-next/src/api/Device.ts index dcdfbbac..8b11dacf 100644 --- a/browse-next/src/api/Device.ts +++ b/browse-next/src/api/Device.ts @@ -20,7 +20,11 @@ import type { DeviceEvent, IsoFormattedString, } from "@typedefs/api/event"; -import type { DeviceEventType } from "@typedefs/api/consts"; +import type { + DeviceEventType, + DeviceType, + DeviceTypeUnion, +} from "@typedefs/api/consts"; import type { ApiStationResponse as ApiLocationResponse } from "@typedefs/api/station"; import type { ApiRecordingResponse } from "@typedefs/api/recording"; import type { ApiTrackResponse } from "@typedefs/api/track"; @@ -552,11 +556,9 @@ export const getMaskRegionsForDevice = ( export const getSettingsForDevice = ( deviceId: DeviceId, - atTime?: Date, lastSynced = false ) => { const params = new URLSearchParams(); - params.append("at-time", (atTime || new Date()).toISOString()); if (lastSynced) { params.append("latest-synced", true.toString()); } @@ -667,31 +669,7 @@ export const getLastKnownDeviceBatteryLevel = ( }; export const getDeviceModel = async (deviceId: DeviceId) => { - try { - const nodegroup = await getDeviceNodeGroup(deviceId); - if (nodegroup) { - const model = nodegroup.includes("tc2") - ? "tc2" - : nodegroup.includes("pi") - ? "pi" - : null; - if (model !== null) { - return model; - } - } - return await getLatestEventsByDeviceId(deviceId, { - type: "versionData", - limit: 1, - }).then((response) => { - if (response.success && response.result.rows.length) { - return response.result.rows[0].EventDetail.details["tc2-agent"] - ? "tc2" - : "pi"; - } else { - return null; - } - }); - } catch (e) { - return null; - } + return CacophonyApi.get(`/api/v1/devices/${deviceId}/type`) as Promise< + FetchResult<{ type: DeviceTypeUnion }> + >; }; diff --git a/browse-next/src/components/DeviceRecordingSetup.vue b/browse-next/src/components/DeviceRecordingSetup.vue index 71c618bf..bf3e199a 100644 --- a/browse-next/src/components/DeviceRecordingSetup.vue +++ b/browse-next/src/components/DeviceRecordingSetup.vue @@ -18,12 +18,13 @@ import { import Datepicker from "@vuepic/vue-datepicker"; import { projectDevicesLoaded } from "@models/LoggedInUser.ts"; import { resourceIsLoading } from "@/helpers/utils.ts"; +import type { DeviceTypeUnion } from "@typedefs/api/consts"; type Time = { hours: number; minutes: number; seconds: number }; const devices = inject(selectedProjectDevices) as Ref< ApiDeviceResponse[] | null >; const route = useRoute(); -const deviceModel = ref>(null); +const deviceModel = ref>(null); // Device Settings const settings = ref>(null); const syncedSettings = ref>(null); @@ -54,9 +55,10 @@ const device = computed(() => { const settingsLoading = resourceIsLoading(settings); const lastSyncedSettingsLoading = resourceIsLoading(lastSyncedSettings); + const nodeGroupInfoLoading = resourceIsLoading(deviceModel); const isTc2Device = computed(() => { - return deviceModel.value === "tc2"; + return deviceModel.value === "hybrid-thermal-audio"; }); const defaultWindows = { powerOn: "-30m", @@ -154,15 +156,16 @@ const initialised = ref(false); onBeforeMount(async () => { await projectDevicesLoaded(); await loadResource(settings, fetchSettings); - await loadResource(deviceModel, () => getDeviceModel(deviceId.value)); + await loadResource(deviceModel, async () => { + const res = await getDeviceModel(deviceId.value); + if (res.success) { + return res.result.type; + } + }); initialised.value = true; if (settings.value && !settings.value.synced) { // Load last synced settings - const response = await getSettingsForDevice( - deviceId.value, - new Date(), - true - ); + const response = await getSettingsForDevice(deviceId.value, true); if (response && response.success && response.result.settings) { syncedSettings.value = response.result.settings; } @@ -177,10 +180,13 @@ const useLowPowerMode = computed({ ); }, set: (val: boolean) => { - (settings.value as ApiDeviceHistorySettings).thermalRecording = { - useLowPowerMode: val, - updated: new Date().toISOString(), - }; + if (settings.value) { + (settings.value as ApiDeviceHistorySettings).thermalRecording = { + useLowPowerMode: val, + updated: new Date().toISOString(), + }; + settings.value.synced = false; + } }, }); const recordingWindowSetting = computed<"default" | "always" | "custom">({ @@ -235,6 +241,7 @@ const recordingWindowSetting = computed<"default" | "always" | "custom">({ updated: new Date().toISOString(), }; } + settings.value.synced = false; } }, }); @@ -257,6 +264,7 @@ const customRecordingWindowStart = computed