Skip to content

Commit a0f8cb0

Browse files
committed
fix(web): persist hardware sensor assignment
1 parent b39f438 commit a0f8cb0

10 files changed

Lines changed: 221 additions & 17 deletions

File tree

CHANGELOG.md

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,30 @@ Format: [Keep a Changelog](https://keepachangelog.com/en/1.1.0/) · SemVer.
44

55
## [Unreleased]
66

7+
## [2.1.0] – 2026-06-20
8+
9+
### Added
10+
- Production cloud map, scoped realtime bin fullness, collection alerts, and the
11+
Admin-only hardware bridge for camera/live operations.
12+
- DeepSeek-backed EcoPet chat with Vietnamese quality safeguards, role-scoped
13+
context, User monthly quota, and a safe Vietnamese fallback.
14+
- A persistent hardware-sensor assignment on the map: Admin maps one physical
15+
`BIN:1`, `BIN:2`, or `BIN:3` sensor reading to one cloud bin; Users can view
16+
the live result but cannot change the shared hardware mapping.
17+
- Detailed operator guide for the map-to-hardware sensor flow and release notes.
18+
19+
### Changed
20+
- Map sensor controls now explain their purpose, state that they never reset a
21+
fullness percentage, and keep the action on a separate row to avoid overlap.
22+
- The persisted sensor assignment is reloaded after a page refresh, so the map
23+
continues to show the bin currently receiving hardware data.
24+
25+
### Fixed
26+
- Removed the misleading generic “Chọn” map control and restored a visible
27+
success message on the User map.
28+
- Hardened bridge restart/reconnection and reduced the demo-target database
29+
write to one scoped upsert.
30+
731
### Fixed
832
- Stabilized Live labels and servo dispatch by tracking route consensus across
933
class-name changes for the same physical object.

README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,7 @@ Trash Sorter Pro là một hệ sinh thái AI phân loại rác end-to-end:
5353
- [Supabase/cloud readiness](docs/supabase-full-cloud-readiness.md)
5454
- [Checklist tích hợp phần cứng](docs/hardware_integration_checklist.md)
5555
- [Operations map local-first](docs/operations-map-local-first.md)
56+
- [Release v2.1.0: cloud map and hardware bridge](docs/releases/v2.1.0.md)
5657

5758
Hướng dẫn chạy web app cho đồng nghiệp: [docs/huong-dan-chay-web-app.md](docs/huong-dan-chay-web-app.md).
5859

docs/releases/v2.1.0.md

Lines changed: 129 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,129 @@
1+
# Trash Sorter Pro v2.1.0 — Cloud Map & Hardware Bridge
2+
3+
**Released:** 2026-06-20
4+
**Production:** <https://trash-sorter-v2.vercel.app>
5+
**Scope:** desktop recognition, cloud dashboard, realtime bin map, EcoPet AI,
6+
and the secure Admin hardware bridge.
7+
8+
## What people can understand at a glance
9+
10+
Trash Sorter Pro has two connected applications:
11+
12+
| Application | Who uses it | Main job |
13+
| --- | --- | --- |
14+
| Desktop app | Operator/Admin at the machine | Runs the USB camera, YOLO detection, UART/Arduino commands, sound, and reads physical fullness sensors. |
15+
| Web dashboard | Admin and User | Shows cloud data: bins, alerts, collection schedules, Eco Score, reports, and EcoPet AI. |
16+
17+
The web dashboard does **not** drive a User’s camera or machine. Admin camera
18+
and training actions travel through the protected hardware bridge; ordinary map
19+
and chat data use the cloud database directly.
20+
21+
## Map sensor button: exact meaning
22+
23+
The map control is intentionally **not a reset button**.
24+
25+
`Gán cảm biến` means: *choose which cloud bin receives a physical fullness
26+
reading from the hardware machine.* For example, choosing the recyclable bin
27+
with index `3` maps the next `BIN:3:87` UART reading to that selected bin. The
28+
web map then updates to `87%`, creates/updates the appropriate alert, and User
29+
views receive the realtime update.
30+
31+
```mermaid
32+
flowchart LR
33+
A[HC-SR04 / firmware] -->|BIN:3:87| B[Desktop agent]
34+
B --> C[Supabase hardware synchronizer]
35+
C -->|looks up current Admin assignment| D[demo_hardware_targets]
36+
D --> E[Selected bin in bins table]
37+
E --> F[Realtime event and alert]
38+
F --> G[Admin and assigned User map]
39+
```
40+
41+
### What Admin sees
42+
43+
1. Open **Bản đồ thùng**.
44+
2. Open a station marker.
45+
3. Read the green explanation block: it says the action assigns a physical
46+
sensor and does not reset the current fill percentage.
47+
4. Press **Gán cảm biến** on the intended O/R/I bin.
48+
5. The action changes to **Đang nhận dữ liệu cảm biến** and a success message
49+
confirms the `BIN:<index>` mapping, station, and non-reset behavior.
50+
6. Refreshing the page keeps the selected state because the assignment is read
51+
back from Supabase.
52+
53+
Only one physical demo target is active at once. Selecting a different bin
54+
changes the destination for future hardware readings; it does not modify old
55+
history, erase alerts, or set a percentage to zero.
56+
57+
### What User sees
58+
59+
Users can see the assigned station, current fullness, and the label
60+
**Cảm biến phần cứng đang đồng bộ**. They cannot change the mapping. This avoids
61+
one User accidentally rerouting the shared physical sensor away from another
62+
station.
63+
64+
### What happens when the bin is full
65+
66+
- Under `80%`: normal state.
67+
- From `80%` to `94%`: warning / near full.
68+
- From `95%`: full state and an alert is created or refreshed.
69+
- Map refresh: authenticated User map checks realtime events every 1.2 seconds;
70+
the regular refresh remains as a fallback.
71+
72+
## Security and role boundaries
73+
74+
| Action | Admin | User |
75+
| --- | --- | --- |
76+
| Assign the shared physical sensor target | Yes | No |
77+
| View assigned bins and realtime fullness | Yes | Yes, only assigned stations |
78+
| Use camera/live/training through bridge | Yes | No |
79+
| Read another account’s map/history | No special restriction for operations | No |
80+
81+
The assignment is validated server-side against an active station and active
82+
bin. A User cannot forge an owner name. The bridge exports neither images,
83+
tokens, raw logs, passwords, nor arbitrary local endpoints.
84+
85+
## Operating the bridge
86+
87+
On the hardware computer, run:
88+
89+
```powershell
90+
powershell -ExecutionPolicy Bypass -File scripts/start_public_hardware_bridge.ps1
91+
```
92+
93+
The script starts or reuses the local agent, Supabase state synchronizer, and
94+
Cloudflare tunnel. Copy its HTTPS URL into Vercel Production as
95+
`TRASH_SORTER_HARDWARE_BRIDGE_URL`; the bridge secret must match
96+
`TRASH_SORTER_HARDWARE_BRIDGE_SECRET` on both sides.
97+
98+
For a real deployment, use a named Cloudflare Tunnel or service manager. A
99+
Quick Tunnel URL changes whenever `cloudflared` restarts, so Vercel must be
100+
updated and redeployed after a URL change.
101+
102+
## EcoPet AI in this release
103+
104+
- Runs server-side with DeepSeek; no API key is exposed to the browser.
105+
- Answers in Vietnamese with diacritics, short practical guidance, and
106+
role-scoped context.
107+
- User quota is 36 requests per month.
108+
- If AI is unavailable or returns poor Vietnamese text, EcoPet uses a safe
109+
Vietnamese fallback and does not invent machine status.
110+
111+
## Verification completed
112+
113+
- Web build completed successfully.
114+
- Unit suite: **89/89 passed**.
115+
- Production alias and external bridge health endpoint were verified before
116+
this release candidate.
117+
118+
## Upgrade checklist
119+
120+
1. Apply the Supabase migrations, including
121+
`202606180004_demo_hardware_targets.sql`.
122+
2. Set `NEXT_PUBLIC_DEMO_HARDWARE_TARGET=1` on Vercel only when the demo sensor
123+
control is intentionally enabled.
124+
3. Set `TRASH_SORTER_DEMO_HARDWARE_TARGET=1` only on the hardware bridge.
125+
4. Confirm a bridge health response before enabling Admin camera controls.
126+
5. Sign in as Admin, assign one sensor target, then send a safe test reading
127+
such as `BIN:3:65` from the hardware firmware.
128+
6. Sign in as the assigned User and confirm the map updates without exposing
129+
Admin controls.
Lines changed: 6 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -1,22 +1,15 @@
11
import { NextRequest, NextResponse } from "next/server";
22

3-
import { cloudSetDemoHardwareTarget } from "@/lib/server/cloud-operations";
43
import { handleCloudUserRequest } from "@/lib/server/cloud-user-route";
54

65
export const runtime = "nodejs";
76
export const dynamic = "force-dynamic";
87

98
export async function POST(request: NextRequest) {
10-
let payload: Record<string, unknown>;
11-
try {
12-
payload = await request.json();
13-
} catch {
14-
return NextResponse.json({ detail: "Invalid JSON body" }, { status: 400 });
15-
}
16-
return handleCloudUserRequest(request, async (identity) => {
17-
const result = await cloudSetDemoHardwareTarget(identity, payload);
18-
if (!result) return NextResponse.json({ detail: "Station or bin is outside this User scope" }, { status: 404 });
19-
if ("disabled" in result && result.disabled) return NextResponse.json(result, { status: 404 });
20-
return result;
21-
});
9+
return handleCloudUserRequest(request, async () =>
10+
NextResponse.json(
11+
{ detail: "Only an Admin can assign the shared hardware sensor to a map bin." },
12+
{ status: 403 }
13+
)
14+
);
2215
}

web/src/app/globals.css

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1424,6 +1424,18 @@ input[type="file"] {
14241424
opacity: 1;
14251425
}
14261426

1427+
.popup-sensor-target-status {
1428+
background: rgba(5, 150, 105, 0.1);
1429+
border: 1px solid rgba(5, 150, 105, 0.3);
1430+
border-radius: 7px;
1431+
color: #047857;
1432+
font-size: 11px;
1433+
font-weight: 800;
1434+
grid-column: 2 / -1;
1435+
line-height: 18px;
1436+
padding: 5px 8px;
1437+
}
1438+
14271439
.popup-bin-row > span,
14281440
.popup-bin-row > strong {
14291441
font-size: 12px;

web/src/components/dashboard-client.tsx

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1059,7 +1059,7 @@ export function DashboardClient() {
10591059
return;
10601060
}
10611061
const role = auth?.role;
1062-
if (role !== "admin" && role !== "user") {
1062+
if (role !== "admin") {
10631063
setAgentError("Bạn cần đăng nhập để chọn thùng demo.");
10641064
return;
10651065
}
@@ -1076,7 +1076,7 @@ export function DashboardClient() {
10761076
setAgentError("");
10771077
try {
10781078
const result = await cloudFetch<DemoHardwareTargetResponse>(
1079-
role === "admin" ? "/api/admin/demo-bin-target" : "/api/user/demo-bin-target",
1079+
"/api/admin/demo-bin-target",
10801080
{
10811081
method: "POST",
10821082
headers: { "Content-Type": "application/json" },
@@ -1116,6 +1116,14 @@ export function DashboardClient() {
11161116
}
11171117
}, [agentToken, auth?.role]);
11181118

1119+
useEffect(() => {
1120+
const map = auth?.role === "admin" ? adminBinMap : auth?.role === "user" ? userBinMap : null;
1121+
const target = map?.sensor_target;
1122+
const targetKey = target ? `${target.station_id}:${target.bin_id}` : "";
1123+
setSelectedDemoBinKey(targetKey);
1124+
selectedDemoBinKeyRef.current = targetKey;
1125+
}, [adminBinMap, auth?.role, userBinMap]);
1126+
11191127
async function reportDeviceIssue(payload: DeviceIssueCreatePayload) {
11201128
setBusy(true);
11211129
try {

web/src/components/operations-bin-map.tsx

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -765,6 +765,9 @@ function BinFillRow({
765765
{selected ? (pending ? "Đang kích hoạt cảm biến..." : "Đang nhận dữ liệu cảm biến") : pending ? "Đang kích hoạt..." : "Gán cảm biến"}
766766
</button>
767767
) : null}
768+
{selected && !demoTargetEnabled ? (
769+
<span className="popup-sensor-target-status">Cảm biến phần cứng đang đồng bộ</span>
770+
) : null}
768771
</div>
769772
);
770773
}

web/src/components/user-dashboard/user-route-content.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -42,7 +42,7 @@ export function UserRouteContent(props: UserDashboardPanelProps) {
4242
selectedDemoBinKey={props.selectedDemoBinKey}
4343
onMapInteraction={props.onUserMapInteraction}
4444
onRefresh={props.onRefreshOperations}
45-
demoTargetEnabled={process.env.NEXT_PUBLIC_DEMO_HARDWARE_TARGET === "1"}
45+
demoTargetEnabled={false}
4646
onSelectDemoBin={props.onSelectDemoBin}
4747
/>
4848
);

web/src/lib/agent.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -623,6 +623,12 @@ export type BinMapResponse = {
623623
};
624624
stations: BinStation[];
625625
total: number;
626+
sensor_target?: {
627+
station_id: string;
628+
bin_id: string;
629+
bin_index: number;
630+
selected_at: string;
631+
} | null;
626632
};
627633

628634
export type BinStationCreatePayload = {

web/src/lib/server/cloud-operations.ts

Lines changed: 29 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -185,12 +185,40 @@ export async function cloudBinMap(identity: CloudAuthIdentity, includeInactive =
185185
const childBinsByStation = await binsByStation(stationIds);
186186
const alertsByStation = await openAlertCountsByStation(stationIds, owner);
187187
const stations = stationRows.map((row) => stationDto(row, childBinsByStation.get(row.station_id) ?? [], alertsByStation));
188+
const sensorTarget = await cloudDemoHardwareTarget(identity);
188189
return {
189190
generated_at: new Date().toISOString(),
190191
center: DEFAULT_CENTER,
191192
stations,
192193
total: stations.length,
193-
seed_source: SEED_SOURCE
194+
seed_source: SEED_SOURCE,
195+
sensor_target: sensorTarget
196+
};
197+
}
198+
199+
async function cloudDemoHardwareTarget(identity: CloudAuthIdentity) {
200+
if (!demoHardwareTargetEnabled()) {
201+
return null;
202+
}
203+
const owner = identity.role === "user" ? identity.username : "";
204+
const result = await pool().query<DemoHardwareTargetRow>(
205+
`select owner_username, station_id, bin_id, bin_index, selected_by, selected_at, active
206+
from public.demo_hardware_targets
207+
where active = true
208+
and ($1::text = '' or owner_username = $1)
209+
order by selected_at desc
210+
limit 1`,
211+
[owner]
212+
);
213+
const target = result.rows[0];
214+
if (!target) {
215+
return null;
216+
}
217+
return {
218+
station_id: target.station_id,
219+
bin_id: target.bin_id,
220+
bin_index: Number(target.bin_index),
221+
selected_at: iso(target.selected_at)
194222
};
195223
}
196224

0 commit comments

Comments
 (0)