Skip to content

Commit

Permalink
Merge branch 'reference-photos' of https://github.com/TheCacophonyPro…
Browse files Browse the repository at this point in the history
…ject/cacophony-web into audio-views
  • Loading branch information
hardiesoft committed Feb 19, 2025
2 parents 92d4160 + c99c3e3 commit 10a3dd5
Show file tree
Hide file tree
Showing 18 changed files with 1,797 additions and 793 deletions.
1,182 changes: 731 additions & 451 deletions api/api/V1/Device.ts

Large diffs are not rendered by default.

2 changes: 1 addition & 1 deletion api/api/V1/Group.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
76 changes: 76 additions & 0 deletions api/api/V1/Station.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 type { Device } from "@/models/Device.js";

const models = await modelsInit();

Expand Down Expand Up @@ -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 });
}
);
}
24 changes: 24 additions & 0 deletions api/api/V1/recordingUtil.ts
Original file line number Diff line number Diff line change
Expand Up @@ -527,6 +527,28 @@ export const maybeUpdateDeviceHistory = async (
stationToAssignToRecording: Station;
deviceHistoryEntry: DeviceHistory;
}> => {
if (location.lat === 0 || location.lng === 0) {
const existingHistory = await models.DeviceHistory.findOne({
where: {
uuid: device.uuid,
GroupId: device.GroupId,
location: { [Op.ne]: null },
stationId: { [Op.ne]: null },
fromDateTime: { [Op.lte]: dateTime },
},
order: [["fromDateTime", "DESC"]], // Get the latest one that's earlier than our current dateTime
});
if (existingHistory) {
const station = await models.Station.findByPk(existingHistory.stationId);
return {
stationToAssignToRecording: station,
deviceHistoryEntry: existingHistory,
};
}
throw new Error(
"Invalid location provided (lat or lng is 0) and no device history exists."
);
}
{
// Update the device location on config change. (It gets updated elsewhere if a newer recording comes in)
const lastLocation = device.location;
Expand Down Expand Up @@ -683,6 +705,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;
Expand Down
34 changes: 34 additions & 0 deletions api/api/fileUploaders/uploadGenericRecording.ts
Original file line number Diff line number Diff line change
Expand Up @@ -441,6 +441,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;
}
Expand Down Expand Up @@ -628,6 +651,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;

Expand Down
35 changes: 31 additions & 4 deletions api/models/DeviceHistory.ts
Original file line number Diff line number Diff line change
Expand Up @@ -56,7 +56,8 @@ export interface DeviceHistoryStatic extends ModelStaticCommon<DeviceHistory> {
latest(
deviceId: DeviceId,
groupId: GroupId,
atTime?: Date
atTime?: Date,
where?: any
): Promise<DeviceHistory | null>;
getEarliestFromDateTimeForDeviceAtCurrentLocation(
deviceId: DeviceId,
Expand Down Expand Up @@ -134,17 +135,43 @@ export default function (
DeviceHistory.latest = async function (
deviceId: DeviceId,
groupId: GroupId,
atTime = new Date()
atTime = new Date(),
where = {}
): Promise<DeviceHistory | null> {
return this.findOne({
// Find the closest entry before (or at) atTime
const before = await this.findOne({
where: {
DeviceId: deviceId,
GroupId: groupId,
location: { [Op.ne]: null },
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 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;
}

// If only one exists, return that one.
return before || after;
};
DeviceHistory.updateDeviceSettings = async function (
deviceId: DeviceId,
Expand Down
36 changes: 31 additions & 5 deletions api/models/util/locationUtils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -146,16 +146,42 @@ export const canonicalLatLng = (
}
return location as LatLng;
};
const EARTH_RADIUS = 6371000; // Earth's radius in meters.
const toRadians = (deg: number): number => (deg * Math.PI) / 180;
/**
* Computes the distance between two points on the Earth using the Haversine formula.
*
* @param a - The first location.
* @param b - The second location.
* @returns The distance between the two points in meters.
*/
export const haversineDistance = (a: LatLng, b: LatLng): number => {
const dLat = toRadians(b.lat - a.lat);
const dLng = toRadians(b.lng - a.lng);
const lat1 = toRadians(a.lat);
const lat2 = toRadians(b.lat);

const havA =
Math.sin(dLat / 2) * Math.sin(dLat / 2) +
Math.cos(lat1) * Math.cos(lat2) * Math.sin(dLng / 2) * Math.sin(dLng / 2);
const havC = 2 * Math.atan2(Math.sqrt(havA), Math.sqrt(1 - havA));
return EARTH_RADIUS * havC;
};

/**
* Compares two locations, treating them as equal if they are within 5 meters of each other.
*
* @param a - The first location.
* @param b - The second location.
* @returns True if the locations are within 5 meters; otherwise, false.
*/
export const locationsAreEqual = (
a: LatLng | { coordinates: [number, number] },
b: LatLng | { coordinates: [number, number] }
): boolean => {
const canonicalA = canonicalLatLng(a);
const canonicalB = canonicalLatLng(b);
// NOTE: We need to compare these numbers with an epsilon value, otherwise we get floating-point precision issues.
return (
Math.abs(canonicalA.lat - canonicalB.lat) < EPSILON &&
Math.abs(canonicalA.lng - canonicalB.lng) < EPSILON
);
const toleranceInMeters = 5; // 5 meters tolerance

return haversineDistance(canonicalA, canonicalB) < toleranceInMeters;
};
42 changes: 10 additions & 32 deletions browse-next/src/api/Device.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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());
}
Expand Down Expand Up @@ -601,7 +603,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<FetchResult<Blob>>;
};

Expand All @@ -617,7 +619,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<
Expand Down Expand Up @@ -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 }>
>;
};
Loading

0 comments on commit 10a3dd5

Please sign in to comment.