Skip to content

Commit 2020dce

Browse files
author
Ravi Singh
committed
feat: seamless QR claim — receiver serves redirect page
QR code now points to http://<receiver-ip>/claim which: 1. Collects tank data locally (same-origin HTTP, no mixed content) 2. Redirects to https://tanksync.smartghar.org/link with tanks encoded in URL 3. Cloud creates site + MQTT credentials 4. Success page shows MQTT config (auto-push or manual instructions) This solves the HTTPS→HTTP mixed content issue that prevented the cloud page from talking to the local receiver.
1 parent c15faad commit 2020dce

3 files changed

Lines changed: 172 additions & 26 deletions

File tree

firmware/receiver-c3/components/web_server/web_server.c

Lines changed: 60 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -526,6 +526,62 @@ static void ensure_link_token(void) {
526526
snprintf(s_link_token, sizeof(s_link_token), "%08" PRIx32, r1);
527527
}
528528

529+
// Claim page — served by receiver (HTTP, same-origin). Collects tank data
530+
// locally, then redirects to the cloud with everything URL-encoded.
531+
// This avoids the HTTPS→HTTP mixed content browser restriction.
532+
static esp_err_t handle_claim_page(httpd_req_t *req) {
533+
ensure_link_token();
534+
const char *dev_id = mqtt_manager_device_id();
535+
const char *ip = wifi_manager_ip();
536+
537+
// Build tank data JSON inline
538+
char tanks_json[512] = "[]";
539+
{
540+
cJSON *arr = cJSON_CreateArray();
541+
for (int i = 0; i < registry_count(); i++) {
542+
tx_info_t info; tx_data_t data;
543+
if (!registry_get_info(i, &info) || !info.enabled) continue;
544+
registry_get_data(i, &data);
545+
cJSON *t = cJSON_CreateObject();
546+
cJSON_AddNumberToObject(t, "address", info.address);
547+
cJSON_AddStringToObject(t, "name", info.name);
548+
cJSON_AddNumberToObject(t, "min_dist", info.min_dist_cm);
549+
cJSON_AddNumberToObject(t, "max_dist", info.max_dist_cm);
550+
cJSON_AddNumberToObject(t, "capacity", info.capacity_liters);
551+
cJSON_AddItemToArray(arr, t);
552+
}
553+
char *s = cJSON_PrintUnformatted(arr);
554+
if (s) { strncpy(tanks_json, s, sizeof(tanks_json)-1); free(s); }
555+
cJSON_Delete(arr);
556+
}
557+
558+
httpd_resp_set_type(req, "text/html; charset=utf-8");
559+
char *page = malloc(2048);
560+
if (!page) return ESP_FAIL;
561+
int len = snprintf(page, 2048,
562+
"<!DOCTYPE html><html><head><meta charset='utf-8'>"
563+
"<meta name='viewport' content='width=device-width,initial-scale=1'>"
564+
"<title>TankSync — Linking</title>"
565+
"<style>body{background:#0F172A;color:#fff;font-family:sans-serif;"
566+
"display:flex;align-items:center;justify-content:center;min-height:100vh;margin:0;text-align:center}"
567+
".spinner{width:48px;height:48px;border:4px solid rgba(14,165,233,0.3);"
568+
"border-top-color:#0EA5E9;border-radius:50%%;animation:spin 1s linear infinite;margin:0 auto 16px}"
569+
"@keyframes spin{to{transform:rotate(360deg)}}</style></head>"
570+
"<body><div><div class='spinner'></div>"
571+
"<h2>Linking to TankSync Cloud</h2>"
572+
"<p style='opacity:0.6'>Redirecting...</p>"
573+
"<script>"
574+
"var tanks=encodeURIComponent(JSON.stringify(%s));"
575+
"var url='%s/link?id=%s&token=%s&ip=%s&tanks='+tanks;"
576+
"window.location.href=url;"
577+
"</script></div></body></html>",
578+
tanks_json, TANKSYNC_CLOUD_URL, dev_id, s_link_token, ip);
579+
580+
httpd_resp_send(req, page, len);
581+
free(page);
582+
return ESP_OK;
583+
}
584+
529585
static esp_err_t handle_api_link(httpd_req_t *req) {
530586
ensure_link_token();
531587
const char *dev_id = mqtt_manager_device_id();
@@ -539,7 +595,8 @@ static esp_err_t handle_api_link(httpd_req_t *req) {
539595

540596
// Build the claim URL
541597
char url[256];
542-
snprintf(url, sizeof(url), TANKSYNC_CLOUD_URL "/link?id=%s&token=%s&ip=%s", dev_id, s_link_token, ip);
598+
// QR URL points to receiver's /claim page (HTTP, avoids mixed content)
599+
snprintf(url, sizeof(url), "http://%s/claim", ip);
543600
cJSON_AddStringToObject(root, "url", url);
544601

545602
char *json = cJSON_PrintUnformatted(root); cJSON_Delete(root); send_json(req, json); free(json);
@@ -860,6 +917,7 @@ static const httpd_uri_t s_routes[] = {
860917
URI(HTTP_POST, "/api/ota/upload", handle_ota_upload), URI(HTTP_POST, "/api/ota/upload_tx", handle_ota_upload_tx),
861918
URI(HTTP_GET, "/api/display", handle_get_display), URI(HTTP_POST, "/api/display", handle_post_display),
862919
URI(HTTP_GET, "/api/link", handle_api_link),
920+
URI(HTTP_GET, "/claim", handle_claim_page),
863921
// Captive portal detection endpoints
864922
URI(HTTP_GET, "/hotspot-detect.html", handle_captive_redirect), // iOS
865923
URI(HTTP_GET, "/library/test/success.html", handle_captive_redirect), // iOS alternate
@@ -873,7 +931,7 @@ static const httpd_uri_t s_routes[] = {
873931
esp_err_t web_server_start(void) {
874932
httpd_config_t cfg = HTTPD_DEFAULT_CONFIG();
875933
cfg.server_port = WEB_PORT;
876-
cfg.max_uri_handlers = 38;
934+
cfg.max_uri_handlers = 40;
877935
cfg.uri_match_fn = httpd_uri_match_wildcard;
878936
cfg.max_open_sockets = 6; // leave 4+ lwIP sockets for MQTT/DNS/NTP
879937
cfg.recv_wait_timeout = 15; // balance: short enough to free sockets, long enough for TX firmware upload

firmware/receiver/components/web_server/web_server.c

Lines changed: 60 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -526,6 +526,62 @@ static void ensure_link_token(void) {
526526
snprintf(s_link_token, sizeof(s_link_token), "%08" PRIx32, r1);
527527
}
528528

529+
// Claim page — served by receiver (HTTP, same-origin). Collects tank data
530+
// locally, then redirects to the cloud with everything URL-encoded.
531+
// This avoids the HTTPS→HTTP mixed content browser restriction.
532+
static esp_err_t handle_claim_page(httpd_req_t *req) {
533+
ensure_link_token();
534+
const char *dev_id = mqtt_manager_device_id();
535+
const char *ip = wifi_manager_ip();
536+
537+
// Build tank data JSON inline
538+
char tanks_json[512] = "[]";
539+
{
540+
cJSON *arr = cJSON_CreateArray();
541+
for (int i = 0; i < registry_count(); i++) {
542+
tx_info_t info; tx_data_t data;
543+
if (!registry_get_info(i, &info) || !info.enabled) continue;
544+
registry_get_data(i, &data);
545+
cJSON *t = cJSON_CreateObject();
546+
cJSON_AddNumberToObject(t, "address", info.address);
547+
cJSON_AddStringToObject(t, "name", info.name);
548+
cJSON_AddNumberToObject(t, "min_dist", info.min_dist_cm);
549+
cJSON_AddNumberToObject(t, "max_dist", info.max_dist_cm);
550+
cJSON_AddNumberToObject(t, "capacity", info.capacity_liters);
551+
cJSON_AddItemToArray(arr, t);
552+
}
553+
char *s = cJSON_PrintUnformatted(arr);
554+
if (s) { strncpy(tanks_json, s, sizeof(tanks_json)-1); free(s); }
555+
cJSON_Delete(arr);
556+
}
557+
558+
httpd_resp_set_type(req, "text/html; charset=utf-8");
559+
char *page = malloc(2048);
560+
if (!page) return ESP_FAIL;
561+
int len = snprintf(page, 2048,
562+
"<!DOCTYPE html><html><head><meta charset='utf-8'>"
563+
"<meta name='viewport' content='width=device-width,initial-scale=1'>"
564+
"<title>TankSync — Linking</title>"
565+
"<style>body{background:#0F172A;color:#fff;font-family:sans-serif;"
566+
"display:flex;align-items:center;justify-content:center;min-height:100vh;margin:0;text-align:center}"
567+
".spinner{width:48px;height:48px;border:4px solid rgba(14,165,233,0.3);"
568+
"border-top-color:#0EA5E9;border-radius:50%%;animation:spin 1s linear infinite;margin:0 auto 16px}"
569+
"@keyframes spin{to{transform:rotate(360deg)}}</style></head>"
570+
"<body><div><div class='spinner'></div>"
571+
"<h2>Linking to TankSync Cloud</h2>"
572+
"<p style='opacity:0.6'>Redirecting...</p>"
573+
"<script>"
574+
"var tanks=encodeURIComponent(JSON.stringify(%s));"
575+
"var url='%s/link?id=%s&token=%s&ip=%s&tanks='+tanks;"
576+
"window.location.href=url;"
577+
"</script></div></body></html>",
578+
tanks_json, TANKSYNC_CLOUD_URL, dev_id, s_link_token, ip);
579+
580+
httpd_resp_send(req, page, len);
581+
free(page);
582+
return ESP_OK;
583+
}
584+
529585
static esp_err_t handle_api_link(httpd_req_t *req) {
530586
ensure_link_token();
531587
const char *dev_id = mqtt_manager_device_id();
@@ -539,7 +595,8 @@ static esp_err_t handle_api_link(httpd_req_t *req) {
539595

540596
// Build the claim URL
541597
char url[256];
542-
snprintf(url, sizeof(url), TANKSYNC_CLOUD_URL "/link?id=%s&token=%s&ip=%s", dev_id, s_link_token, ip);
598+
// QR URL points to receiver's /claim page (HTTP, avoids mixed content)
599+
snprintf(url, sizeof(url), "http://%s/claim", ip);
543600
cJSON_AddStringToObject(root, "url", url);
544601

545602
char *json = cJSON_PrintUnformatted(root); cJSON_Delete(root); send_json(req, json); free(json);
@@ -860,6 +917,7 @@ static const httpd_uri_t s_routes[] = {
860917
URI(HTTP_POST, "/api/ota/upload", handle_ota_upload), URI(HTTP_POST, "/api/ota/upload_tx", handle_ota_upload_tx),
861918
URI(HTTP_GET, "/api/display", handle_get_display), URI(HTTP_POST, "/api/display", handle_post_display),
862919
URI(HTTP_GET, "/api/link", handle_api_link),
920+
URI(HTTP_GET, "/claim", handle_claim_page),
863921
// Captive portal detection endpoints
864922
URI(HTTP_GET, "/hotspot-detect.html", handle_captive_redirect), // iOS
865923
URI(HTTP_GET, "/library/test/success.html", handle_captive_redirect), // iOS alternate
@@ -873,7 +931,7 @@ static const httpd_uri_t s_routes[] = {
873931
esp_err_t web_server_start(void) {
874932
httpd_config_t cfg = HTTPD_DEFAULT_CONFIG();
875933
cfg.server_port = WEB_PORT;
876-
cfg.max_uri_handlers = 38;
934+
cfg.max_uri_handlers = 40;
877935
cfg.uri_match_fn = httpd_uri_match_wildcard;
878936
cfg.max_open_sockets = 6; // leave 4+ lwIP sockets for MQTT/DNS/NTP
879937
cfg.recv_wait_timeout = 15; // balance: short enough to free sockets, long enough for TX firmware upload

pwa/client/src/pages/LinkDevice.jsx

Lines changed: 52 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -21,10 +21,13 @@ export default function LinkDevice() {
2121
const deviceId = searchParams.get('id');
2222
const token = searchParams.get('token');
2323
const receiverIp = searchParams.get('ip');
24+
const tanksParam = searchParams.get('tanks');
2425

2526
const [status, setStatus] = useState('linking');
2627
const [message, setMessage] = useState('');
2728
const [step, setStep] = useState('');
29+
const [mqttCreds, setMqttCreds] = useState(null);
30+
const [mqttAutoConfigured, setMqttAutoConfigured] = useState(false);
2831

2932
useEffect(() => {
3033
if (!deviceId || !token || !receiverIp) setStatus('missing_params');
@@ -43,26 +46,13 @@ export default function LinkDevice() {
4346

4447
const claimDevice = async () => {
4548
try {
46-
// Step 1: Try to verify and discover from receiver (only works if on same LAN + HTTP allowed)
47-
setStep('Discovering tanks...');
49+
// Tanks come from the receiver's /claim redirect (embedded in URL)
50+
setStep('Setting up cloud connection...');
4851
let tanks = [];
49-
let transmitters = [];
50-
try {
51-
const resp = await fetch(`http://${receiverIp}/api/data`, { signal: AbortSignal.timeout(3000) });
52-
const data = await resp.json();
53-
tanks = data.tanks || [];
54-
} catch {
55-
// Mixed content or not on LAN — proceed anyway, server will create empty site
52+
if (tanksParam) {
53+
try { tanks = JSON.parse(tanksParam); } catch {}
5654
}
57-
58-
try {
59-
const txResp = await fetch(`http://${receiverIp}/api/transmitters`, { signal: AbortSignal.timeout(3000) });
60-
const txData = await txResp.json();
61-
transmitters = txData.transmitters || [];
62-
} catch {}
63-
64-
// Step 2: Send to cloud server — token in URL is proof of physical access
65-
setStep('Setting up cloud connection...');
55+
const transmitters = tanks; // same data, has min_dist/max_dist/capacity
6656
const result = await api.post('/api/link/claim', {
6757
device_id: deviceId,
6858
receiver_ip: receiverIp,
@@ -93,12 +83,34 @@ export default function LinkDevice() {
9383
}
9484
}
9585

86+
// Step 3: Try to push MQTT config to receiver directly
87+
let mqttPushed = false;
88+
if (result.mqtt && receiverIp) {
89+
setStep('Configuring receiver...');
90+
try {
91+
const pushResp = await fetch(`http://${receiverIp}/api/mqtt`, {
92+
method: 'POST',
93+
headers: { 'Content-Type': 'application/json' },
94+
body: JSON.stringify({
95+
host: result.mqtt.mqtt_host,
96+
port: result.mqtt.mqtt_port,
97+
user: result.mqtt.mqtt_username,
98+
pass: result.mqtt.mqtt_password,
99+
enabled: true, ha_discovery: false, use_tls: true,
100+
}),
101+
signal: AbortSignal.timeout(5000),
102+
});
103+
mqttPushed = pushResp.ok;
104+
} catch {} // May fail due to mixed content — handled below
105+
}
106+
96107
setStatus('success');
97-
const mqttMsg = result.mqtt ? ' MQTT auto-configured.' : '';
108+
setMqttCreds(result.mqtt);
109+
setMqttAutoConfigured(mqttPushed);
98110
if (result.already_linked) {
99-
setMessage('This device is already linked to your account.' + mqttMsg);
111+
setMessage('This device is already linked to your account.');
100112
} else {
101-
setMessage(`Linked successfully! ${result.device_count || 0} tank${result.device_count !== 1 ? 's' : ''} discovered.${mqttMsg}`);
113+
setMessage(`Linked successfully! ${result.device_count || 0} tank${result.device_count !== 1 ? 's' : ''} discovered.`);
102114
}
103115
} catch (err) {
104116
setStatus('error');
@@ -140,7 +152,25 @@ export default function LinkDevice() {
140152
</svg>
141153
</div>
142154
<h2 className="text-2xl font-bold text-white mb-2">Device Linked!</h2>
143-
<p className="text-slate-400 text-sm mb-8">{message}</p>
155+
<p className="text-slate-400 text-sm mb-4">{message}</p>
156+
157+
{mqttAutoConfigured ? (
158+
<div className="bg-success/10 border border-success/30 rounded-xl px-4 py-3 mb-6 text-sm text-success">
159+
MQTT auto-configured. Your receiver will connect to the cloud automatically.
160+
</div>
161+
) : mqttCreds ? (
162+
<div className="bg-warning/10 border border-warning/30 rounded-xl px-4 py-3 mb-4 text-sm text-left">
163+
<p className="text-warning font-medium mb-2">Configure MQTT on your receiver:</p>
164+
<p className="text-slate-300 text-xs font-mono mb-1">Host: {mqttCreds.mqtt_host}</p>
165+
<p className="text-slate-300 text-xs font-mono mb-1">Port: {mqttCreds.mqtt_port}</p>
166+
<p className="text-slate-300 text-xs font-mono mb-1">User: {mqttCreds.mqtt_username}</p>
167+
<p className="text-slate-300 text-xs font-mono mb-1">Pass: {mqttCreds.mqtt_password}</p>
168+
<p className="text-slate-300 text-xs font-mono mb-2">TLS: Enabled</p>
169+
<a href={`http://${receiverIp}`} target="_blank" rel="noopener"
170+
className="inline-block text-water text-xs underline">Open Receiver Web UI</a>
171+
</div>
172+
) : null}
173+
144174
<button onClick={() => window.location.href = '/'}
145175
className="w-full max-w-xs py-3.5 rounded-xl bg-water text-white font-semibold
146176
hover:bg-water-dark active:scale-[0.98] transition-all">

0 commit comments

Comments
 (0)