Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -18,9 +18,10 @@ When adding a new entry, please use the following format:

## Log

- [2026-05-08] feature: Added /cell/id/sensors endpoint to query sensors associated with each cell
- [2026-04-16] feature: added a clear all cells button to cell selection
- [2026-04-16] fix: removed two scrollbars from dashboard, made power datapoints display as V and A, made data not truncate as often
- [2026-03-30] fix: added test decorators to resolve lack of "TTN_API_KEY" on fork PR's. removed k6 from github actions. changed github action to utilize env-import.py as opposeed to directly accessing s3 bucket for env variables. [#736] (https://github.com/jlab-sensing/ENTS-backend/pull/736)
- [2026-03-30] fix: added test decorators to resolve lack of "TTN_API_KEY" on fork PR's. removed k6 from github actions. changed github action to utilize env-import.py as opposeed to directly accessing s3 bucket for env variables. [#736](https://github.com/jlab-sensing/ENTS-backend/pull/736)
- [2026-03-23] fix: increase fallback SECRET_KEY length to resolve PyJWT InsecureKeyLengthWarning [#682](https://github.com/jlab-sensing/ENTS-backend/pull/682)
- [2026-03-12] fix: safely catch PyJWT DecodeErrors and remove log spam for invalid tokens [#683](https://github.com/jlab-sensing/ENTS-backend/pull/683)
- [2026-03-12] hotfix: linted AddCellModal, ensured functionality of D10 charts, and added flags to github action pytest for logging [#703](https://github.com/jlab-sensing/ENTS-backend/pull/703)
Expand All @@ -30,5 +31,4 @@ When adding a new entry, please use the following format:
- [2026-03-10] hotfix: Fixed import errors [#698](https://github.com/jlab-sensing/ENTS-backend/pull/698)
- [2026-03-05] chore: remove deprecated import_example_data script and update docs to use ents CLI [#665](https://github.com/jlab-sensing/ENTS-backend/pull/665)
- [2026-03-05] fix: Temp removed "Export to CSV option" hotfix [#671](https://github.com/jlab-sensing/ENTS-backend/pull/671)

- [2026-03-21] fix: Fixed non-deterministic two-week date range fallback bug [#713](https://github.com/jlab-sensing/ENTS-backend/pull/713)
2 changes: 2 additions & 0 deletions backend/api/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -171,6 +171,7 @@ def handle_unsubscribe_cells(data):
from .resources.cell_tags import CellTags, CellTagDetail, CellsByTag
from .resources.cell_users import CellUsers, CellUserDetail, CellByUser, CellShare
from .resources.logger import Logger
from .resources.cell_sensors import Cell_Sensors

from .auth.routes import auth

Expand All @@ -187,6 +188,7 @@ def handle_unsubscribe_cells(data):
api.add_resource(Session_r, "/session")
api.add_resource(User_Data, "/user")
api.add_resource(Status, "/status/<string:id>")
api.add_resource(Cell_Sensors, "/cell/<int:cell_id>/sensors")

# Tag management endpoints
api.add_resource(Tag, "/tag/")
Expand Down
11 changes: 11 additions & 0 deletions backend/api/resources/cell_sensors.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
from flask_restful import Resource
from ..models.sensor import Sensor
from ..schemas.sensor_schema import SensorSchema

sensor_schema = SensorSchema(many=True)


class Cell_Sensors(Resource):
def get(self, cell_id):
sensors = Sensor.query.filter_by(cell_id=cell_id).all()
return sensor_schema.dump(sensors)
14 changes: 14 additions & 0 deletions backend/api/schemas/sensor_schema.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
from .. import ma
from ..models.sensor import Sensor


class SensorSchema(ma.SQLAlchemyAutoSchema):
class Meta:
model = Sensor

id = ma.Integer()
cell_id = ma.Integer()
measurement = ma.String()
data_type = ma.String()
unit = ma.String()
name = ma.String()
30 changes: 30 additions & 0 deletions backend/tests/test_sensor.py
Original file line number Diff line number Diff line change
Expand Up @@ -105,3 +105,33 @@ def test_get_sensor_obj(init_database):
# data_type = db.Column(db.Text(), nullable=False)
# unit = db.Column(db.Text())
# name = db.Column(db.Text(), nullable=False)


def test_sensor_endpoint_returns_only_sensors(init_database):
ts = 1705176162
cell = Cell("test_cell_1", "", 1, 1, False, None)
cell.save()

meas_dict = {
"type": "measurement_type",
"cellId": cell.id,
"data": {
"measurement_name": 1,
},
# types: float, int, text
"data_type": {
"measurement_name": int,
},
"ts": ts,
}
data = Sensor.add_data("measurement_name", "measurement_unit", meas_dict)
sensor = Sensor.query.get(data.sensor_id)

response = init_database.get(f"/api/cell/{cell.id}/sensors")

assert response.status_code == 200
result = response.get_json()

returned_ids = [sensor["id"] for sensor in result]
assert sensor.id in returned_ids
assert all(sensors["cell_id"] == cell.id for sensors in result)
23 changes: 22 additions & 1 deletion frontend/src/__tests__/Profile.test.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
// import AccountInfo from '../pages/profile/components/AccountInfo';
import DeleteCellModal from '../pages/profile/components/DeleteCellModal';
import { describe, it, expect, vi, afterEach } from 'vitest';
import { updateCell, getCells, deleteCell, getUserCells } from '../services/cell';
import { updateCell, getCells, deleteCell, getUserCells, getCellSensors } from '../services/cell';
import { useOutletContext } from 'react-router-dom';
import axios from 'axios';
vi.mock('axios');
Expand Down Expand Up @@ -333,6 +333,27 @@ describe('Cell Service API Functions', () => {
expect(getUserCells).toHaveBeenCalledTimes(1);
});

it('get cell sensors', async () => {
const mockResponse = [{ id: 1, name: 'Mock Cell' }];
axios.get.mockResolvedValue({ data: mockResponse});
const data = await getCellSensors(1);
expect(data).toEqual(mockResponse);
expect(axios.get).toHaveBeenCalledWith(`${process.env.PUBLIC_URL}/api/cell/1/sensors`);
});

it ('returns error when get cell sensors fails', async () =>{
const mockError = {
response: {
data: { error: 'Error getting cell sensors'},
},
};

axios.get.mockRejectedValueOnce(mockError);
const data = await getCellSensors(1);
expect(data).toBeUndefined();
expect(axios.get).toHaveBeenCalledWith(`${process.env.PUBLIC_URL}/api/cell/1/sensors`);
})

// it('fetches cell data', async () => {
// const mockData = { result: 'Mock Cell Data' };
// axios.get.mockResolvedValue({ data: mockData });
Expand Down
38 changes: 37 additions & 1 deletion frontend/src/pages/dashboard/Dashboard.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ import DateRangeNotification from '../../components/DateRangeNotification';
import { useSmartDateRange } from '../../hooks/useSmartDateRange';
import useAxiosPrivate from '../../auth/hooks/useAxiosPrivate';
import useAuth from '../../auth/hooks/useAuth';
import { useCells } from '../../services/cell';
import { useCells, getCellSensors } from '../../services/cell';
import ArchiveModal from './components/ArchiveModal';
import BackBtn from './components/BackBtn';
import CellSelect from './components/CellSelect';
Expand Down Expand Up @@ -35,6 +35,7 @@ function Dashboard() {
const [powerHasData, setPowerHasData] = useState(false);
const [terosHasData, setTerosHasData] = useState(false);
const [liveData, setLiveData] = useState([]);
const [cellSensorsById, setCellSensorsById] = useState({});

// Background streaming data - always collecting in background
const backgroundStreamDataRef = useRef([]);
Expand Down Expand Up @@ -405,6 +406,26 @@ useEffect(() => {
}
};

const selectedCellIds = useMemo(() => {
return selectedCells.map((cell) => cell.id.toString()).sort().join(',');
}, [selectedCells])

useEffect(() => {
const loadCellSensors = async () => {
const sensorsById = {};
const cellIds = selectedCellIds.split(',').filter(Boolean);
for (const cellId of cellIds){
sensorsById[cellId] = await getCellSensors(cellId);
}
setCellSensorsById(sensorsById);
};
if (selectedCellIds){
loadCellSensors();
} else {
setCellSensorsById({});
}
}, [selectedCellIds]);


useEffect(() => {
if (selectedCells.length === 0) {
Expand Down Expand Up @@ -667,6 +688,7 @@ useEffect(() => {
stream={stream}
liveData={liveData}
processedData={processedLiveData.sensors}
cellSensorsById={cellSensorsById}
/>
<UnifiedChart
type='power_current'
Expand All @@ -676,6 +698,7 @@ useEffect(() => {
stream={stream}
liveData={liveData}
processedData={processedLiveData.sensors}
cellSensorsById={cellSensorsById}
/>
<UnifiedChart
type='teros12_vwc'
Expand All @@ -685,6 +708,7 @@ useEffect(() => {
stream={stream}
liveData={liveData}
processedData={processedLiveData.sensors}
cellSensorsById={cellSensorsById}
/>
<UnifiedChart
type='teros12_vwc_adj'
Expand All @@ -694,6 +718,7 @@ useEffect(() => {
stream={stream}
liveData={liveData}
processedData={processedLiveData.sensors}
cellSensorsById={cellSensorsById}
/>
<UnifiedChart
type='teros12_temp'
Expand All @@ -703,6 +728,7 @@ useEffect(() => {
stream={stream}
liveData={liveData}
processedData={processedLiveData.sensors}
cellSensorsById={cellSensorsById}
/>
<UnifiedChart
type='teros12_ec'
Expand All @@ -712,6 +738,7 @@ useEffect(() => {
stream={stream}
liveData={liveData}
processedData={processedLiveData.sensors}
cellSensorsById={cellSensorsById}
/>
<UnifiedChart
type='soilPot'
Expand All @@ -721,6 +748,7 @@ useEffect(() => {
stream={stream}
liveData={liveData}
processedData={processedLiveData.sensors}
cellSensorsById={cellSensorsById}
/>
<UnifiedChart
type='presHum'
Expand All @@ -730,6 +758,7 @@ useEffect(() => {
stream={stream}
liveData={liveData}
processedData={processedLiveData.sensors}
cellSensorsById={cellSensorsById}
/>
<UnifiedChart
type='sensor'
Expand All @@ -739,6 +768,7 @@ useEffect(() => {
stream={stream}
liveData={liveData}
processedData={processedLiveData.sensors}
cellSensorsById={cellSensorsById}
/>
<UnifiedChart
type='co2'
Expand All @@ -748,6 +778,7 @@ useEffect(() => {
stream={stream}
liveData={liveData}
processedData={processedLiveData.sensors}
cellSensorsById={cellSensorsById}
/>
<UnifiedChart
type='temperature'
Expand All @@ -757,6 +788,7 @@ useEffect(() => {
stream={stream}
liveData={liveData}
processedData={processedLiveData.sensors}
cellSensorsById={cellSensorsById}
/>
<UnifiedChart
type='soilHum'
Expand All @@ -766,6 +798,7 @@ useEffect(() => {
stream={stream}
liveData={liveData}
processedData={processedLiveData.sensors}
cellSensorsById={cellSensorsById}
/>
<UnifiedChart
type='waterPress'
Expand All @@ -775,6 +808,7 @@ useEffect(() => {
stream={stream}
liveData={liveData}
processedData={processedLiveData.sensors}
cellSensorsById={cellSensorsById}
/>
<UnifiedChart
type='waterFlow'
Expand All @@ -784,6 +818,7 @@ useEffect(() => {
stream={stream}
liveData={liveData}
processedData={processedLiveData.sensors}
cellSensorsById={cellSensorsById}
/>
<UnifiedChart
type='waterFlowD10'
Expand All @@ -793,6 +828,7 @@ useEffect(() => {
stream={stream}
liveData={liveData}
processedData={processedLiveData.sensors}
cellSensorsById={cellSensorsById}
/>
</Stack>
</>
Expand Down
17 changes: 15 additions & 2 deletions frontend/src/pages/dashboard/components/UnifiedChart.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import { getSensorData } from '../../../services/sensor';
import UniversalChart from '../../../charts/UniversalChart';
import { extractUnifiedStreamValue, matchesSensorStreamType, normalizeUnifiedStreamValue } from './unifiedChartUtils';


const CHART_CONFIGS = {
power_voltage: {
sensor_name: 'POWER_VOLTAGE',
Expand Down Expand Up @@ -135,7 +136,7 @@ const CHART_CONFIGS = {
chartId: 'waterFlow',
},
};
function UnifiedChart({ type, cells, startDate, endDate, stream, liveData, processedData, onDataStatusChange }) {
function UnifiedChart({ type, cells, startDate, endDate, stream, liveData, processedData, onDataStatusChange, cellSensorsById ={}, }) {
const [resample, setResample] = useState('hour');
const chartSettings = {
label: [],
Expand Down Expand Up @@ -169,7 +170,18 @@ function UnifiedChart({ type, cells, startDate, endDate, stream, liveData, proce
data[id] = {
name: name,
};
// get list of sensors that are associated with selected cells
const cellSensors = cellSensorsById || [];
for (const meas of measurements) {
// verify sensor mathches one of the sensors from CHART_CONFIGS
const hasSensor = cellSensors.some((sensor) => {
return sensor.name === sensor_name && sensor.measurement === meas;
});
// if no matching sensor, skip this request
if (!hasSensor){
continue;
}

data[id] = {
...data[id],
[meas]: await getSensorData(sensor_name, id, meas, startDate.toHTTP(), endDate.toHTTP(), resample),
Expand Down Expand Up @@ -411,7 +423,7 @@ function UnifiedChart({ type, cells, startDate, endDate, stream, liveData, proce
};

// eslint-disable-next-line react-hooks/exhaustive-deps
}, [cells, stream, resample, startDate, endDate]);
}, [cells, stream, resample, startDate, endDate, cellSensorsById]);

const handleResampleChange = (newResample) => {
setResample(newResample);
Expand Down Expand Up @@ -461,6 +473,7 @@ UnifiedChart.propTypes = {
liveData: PropTypes.array,
processedData: PropTypes.object,
onDataStatusChange: PropTypes.func,
cellSensorsById: PropTypes.object,
};

export default UnifiedChart;
9 changes: 9 additions & 0 deletions frontend/src/services/cell.js
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,15 @@ export const getCells = () => {
});
};

export const getCellSensors = (cellId) => {
return axios
.get(`${process.env.PUBLIC_URL}/api/cell/${cellId}/sensors`)
.then((res) => res.data)
.catch((error) => {
console.log('Error getting cell sensors:', error.response ? error.response.data : error.message);
});
};

// Get cells with their tags included
export const getCellsWithTags = () => {
return axios
Expand Down
Loading