| … | … | @@ -90,10 +90,41 @@ const char *MsgPWMTupleFormatError = "Error: takes up to 6 arguments between 0 a |
| 90 | 90 | // Works around arduino-esp32 issues #1527 (connected() lies after FIN) and #4773 |
| 91 | 91 | // (server.accept() can leave stale clients in place). |
| 92 | 92 | ///////////////////////////////////////////////////////////////////////////////////////// |
| 93 | | WiFiClient LastClient; |
| 94 | | unsigned long lastClientByteMs = 0; |
| 93 | // Several clients may be connected at once (e.g. the Blender addon driving the |
| 94 | // rig while a telnet session is open to inspect it). Previously a single |
| 95 | // LastClient was kept and every new connection stop()ed the incumbent, so two |
| 96 | // controllers evicted each other indefinitely rather than coexisting. |
| 97 | // |
| 98 | // Each slot carries its OWN line buffer. That is required, not cosmetic: |
| 99 | // CmdMgr is one SerialCommand with a single internal buffer, so feeding it |
| 100 | // bytes from two clients as they arrive would splice two commands together and |
| 101 | // corrupt both. Bytes are accumulated per client and only handed over once a |
| 102 | // complete line has arrived, which keeps every command atomic. |
| 103 | const int MAX_CLIENTS = 4; |
| 104 | const int CLIENT_BUF_LEN = 160; // longest real command is a 12-value pwm2 |
| 105 | |
| 106 | struct ClientSlot { |
| 107 | WiFiClient sock; |
| 108 | unsigned long lastByteMs = 0; |
| 109 | char buf[CLIENT_BUF_LEN]; |
| 110 | int len = 0; |
| 111 | }; |
| 112 | ClientSlot clients[MAX_CLIENTS]; |
| 113 | |
| 114 | // Set for the duration of one command dispatch so replies reach whoever asked. |
| 115 | // NULL means the command came from the serial console, so replies go to Serial |
| 116 | // alone. |
| 117 | WiFiClient *ActiveClient = NULL; |
| 118 | |
| 95 | 119 | const unsigned long CLIENT_IDLE_TIMEOUT_MS = 300000; // 5 min — drop a dead session |
| 96 | 120 | |
| 121 | // Write to every connected client, for messages not tied to one request. |
| 122 | void broadcastLine(const char *s) { |
| 123 | for (int i = 0; i < MAX_CLIENTS; i++) { |
| 124 | if (clients[i].sock && clients[i].sock.connected()) clients[i].sock.println(s); |
| 125 | } |
| 126 | } |
| 127 | |
| 97 | 128 | // When true, command handlers also write GOT/OK/ERR markers to the active telnet |
| 98 | 129 | // client so you can see whether commands are reaching the parser. Toggle at runtime |
| 99 | 130 | // with "verbose 0" or "verbose 1". Default on for debugging. |
| … | … | @@ -108,19 +139,19 @@ float channelGamma[3] = {1.0f, 1.0f, 1.0f}; |
| 108 | 139 | |
| 109 | 140 | void diagPrint(const char *s) { |
| 110 | 141 | Serial.print(s); |
| 111 | | if (diagVerbose && LastClient && LastClient.connected()) LastClient.print(s); |
| 142 | if (diagVerbose && ActiveClient && ActiveClient->connected()) ActiveClient->print(s); |
| 112 | 143 | } |
| 113 | 144 | void diagPrint(long v) { |
| 114 | 145 | Serial.print(v); |
| 115 | | if (diagVerbose && LastClient && LastClient.connected()) LastClient.print(v); |
| 146 | if (diagVerbose && ActiveClient && ActiveClient->connected()) ActiveClient->print(v); |
| 116 | 147 | } |
| 117 | 148 | void diagPrintln(const char *s) { |
| 118 | 149 | Serial.println(s); |
| 119 | | if (diagVerbose && LastClient && LastClient.connected()) LastClient.println(s); |
| 150 | if (diagVerbose && ActiveClient && ActiveClient->connected()) ActiveClient->println(s); |
| 120 | 151 | } |
| 121 | 152 | void diagPrintln(long v) { |
| 122 | 153 | Serial.println(v); |
| 123 | | if (diagVerbose && LastClient && LastClient.connected()) LastClient.println(v); |
| 154 | if (diagVerbose && ActiveClient && ActiveClient->connected()) ActiveClient->println(v); |
| 124 | 155 | } |
| 125 | 156 | |
| 126 | 157 | ///////////////////////////////////////////////////////////////////////////////////////// |
| … | … | @@ -287,7 +318,7 @@ void cmdSetFrequency() { |
| 287 | 318 | char buf[80]; |
| 288 | 319 | snprintf(buf, sizeof(buf), "OK freq %d Hz at %d-bit (dutyMax=%d)", freq, res, dutyMax); |
| 289 | 320 | Serial.println(buf); |
| 290 | | if (diagVerbose && LastClient && LastClient.connected()) LastClient.println(buf); |
| 321 | if (diagVerbose && ActiveClient && ActiveClient->connected()) ActiveClient->println(buf); |
| 291 | 322 | } |
| 292 | 323 | |
| 293 | 324 | // pwm2 <numPeriods> <periodLengthMs> <v1> ... <v12> |
| … | … | @@ -460,9 +491,9 @@ void cmdVerbose() { |
| 460 | 491 | } |
| 461 | 492 | Serial.print("verbose "); |
| 462 | 493 | Serial.println(diagVerbose ? "1" : "0"); |
| 463 | | if (LastClient && LastClient.connected()) { |
| 464 | | LastClient.print("verbose "); |
| 465 | | LastClient.println(diagVerbose ? "1" : "0"); |
| 494 | if (ActiveClient && ActiveClient->connected()) { |
| 495 | ActiveClient->print("verbose "); |
| 496 | ActiveClient->println(diagVerbose ? "1" : "0"); |
| 466 | 497 | } |
| 467 | 498 | } |
| 468 | 499 | |
| … | … | @@ -480,7 +511,7 @@ void cmdGamma() { |
| 480 | 511 | snprintf(buf, sizeof(buf), "gamma B=%.2f G=%.2f R=%.2f", |
| 481 | 512 | channelGamma[0], channelGamma[1], channelGamma[2]); |
| 482 | 513 | Serial.println(buf); |
| 483 | | if (LastClient && LastClient.connected()) LastClient.println(buf); |
| 514 | if (ActiveClient && ActiveClient->connected()) ActiveClient->println(buf); |
| 484 | 515 | } |
| 485 | 516 | |
| 486 | 517 | // hostname [name] — get or set the mDNS/OTA hostname. Set persists to NVS and |
| … | … | @@ -510,12 +541,12 @@ void cmdHostname() { |
| 510 | 541 | snprintf(buf, sizeof(buf), |
| 511 | 542 | "hostname stored as '%s' — `reboot` to apply", arg); |
| 512 | 543 | Serial.println(buf); |
| 513 | | if (LastClient && LastClient.connected()) LastClient.println(buf); |
| 544 | if (ActiveClient && ActiveClient->connected()) ActiveClient->println(buf); |
| 514 | 545 | } else { |
| 515 | 546 | char buf[128]; |
| 516 | 547 | snprintf(buf, sizeof(buf), "hostname: %s", currentHostname); |
| 517 | 548 | Serial.println(buf); |
| 518 | | if (LastClient && LastClient.connected()) LastClient.println(buf); |
| 549 | if (ActiveClient && ActiveClient->connected()) ActiveClient->println(buf); |
| 519 | 550 | } |
| 520 | 551 | } |
| 521 | 552 | |
| … | … | @@ -523,10 +554,11 @@ void cmdHostname() { |
| 523 | 554 | // from a stuck state. |
| 524 | 555 | void cmdReboot() { |
| 525 | 556 | Serial.println("Rebooting..."); |
| 526 | | if (LastClient && LastClient.connected()) { |
| 527 | | LastClient.println("Rebooting..."); |
| 528 | | LastClient.flush(); |
| 529 | | LastClient.stop(); |
| 557 | broadcastLine("Rebooting..."); |
| 558 | for (int i = 0; i < MAX_CLIENTS; i++) { |
| 559 | if (!clients[i].sock) continue; |
| 560 | clients[i].sock.flush(); |
| 561 | clients[i].sock.stop(); |
| 530 | 562 | } |
| 531 | 563 | delay(200); // let buffers flush |
| 532 | 564 | ESP.restart(); |
| … | … | @@ -590,7 +622,7 @@ InitState initState = FADE_TEST; |
| 590 | 622 | int wifiWaitCount = 0; |
| 591 | 623 | bool serverStarted = false; |
| 592 | 624 | unsigned long fadeStartMs = 0; |
| 593 | | // LastClient declared at the top with the diag helpers. |
| 625 | // Client slots declared at the top with the diag helpers. |
| 594 | 626 | |
| 595 | 627 | // 10-second boot self-test: fade all pins off→on→off so we can see the chip |
| 596 | 628 | // is alive. Runs before WiFi so it's visible on every cold boot. |
| … | … | @@ -696,31 +728,77 @@ void loop() { |
| 696 | 728 | } |
| 697 | 729 | |
| 698 | 730 | if (serverStarted) { |
| 699 | | WiFiClient newClient = server.accept(); |
| 700 | | if (newClient) { |
| 701 | | if (LastClient) LastClient.stop(); // drop the old session cleanly |
| 702 | | LastClient = newClient; |
| 703 | | lastClientByteMs = millis(); |
| 704 | | Serial.println("New Client."); |
| 705 | | LastClient.print(currentHostname); |
| 706 | | LastClient.println(" ready"); |
| 731 | // --- accept into a free slot; never evict a healthy client --- |
| 732 | WiFiClient incoming = server.accept(); |
| 733 | if (incoming) { |
| 734 | int slot = -1; |
| 735 | for (int i = 0; i < MAX_CLIENTS; i++) { |
| 736 | if (!clients[i].sock || !clients[i].sock.connected()) { slot = i; break; } |
| 737 | } |
| 738 | if (slot < 0) { |
| 739 | // Genuinely full: drop the least recently active, which is the one |
| 740 | // most likely to be dead. Only reachable with MAX_CLIENTS live |
| 741 | // sessions, not on every new connection as before. |
| 742 | unsigned long oldest = ~0UL; // avoids depending on <limits.h> |
| 743 | slot = 0; |
| 744 | for (int i = 0; i < MAX_CLIENTS; i++) { |
| 745 | if (clients[i].lastByteMs < oldest) { oldest = clients[i].lastByteMs; slot = i; } |
| 746 | } |
| 747 | Serial.printf("Client slots full — evicting slot %d\n", slot); |
| 748 | clients[slot].sock.stop(); |
| 749 | } |
| 750 | clients[slot].sock = incoming; |
| 751 | clients[slot].lastByteMs = millis(); |
| 752 | clients[slot].len = 0; |
| 753 | Serial.printf("New client in slot %d\n", slot); |
| 754 | clients[slot].sock.print(currentHostname); |
| 755 | clients[slot].sock.println(" ready"); |
| 707 | 756 | } |
| 708 | | if (LastClient && LastClient.connected()) { |
| 709 | | while (LastClient.available()) { |
| 710 | | char c = LastClient.read(); |
| 711 | | CmdMgr.handleChar(c); |
| 712 | | Serial.write(c); |
| 713 | | lastClientByteMs = millis(); |
| 757 | |
| 758 | // --- service every slot --- |
| 759 | for (int i = 0; i < MAX_CLIENTS; i++) { |
| 760 | ClientSlot &c = clients[i]; |
| 761 | if (!c.sock) continue; |
| 762 | |
| 763 | if (!c.sock.connected()) { |
| 764 | c.sock.stop(); |
| 765 | c.sock = WiFiClient(); |
| 766 | c.len = 0; |
| 767 | continue; |
| 714 | 768 | } |
| 769 | |
| 770 | while (c.sock.available()) { |
| 771 | char ch = c.sock.read(); |
| 772 | c.lastByteMs = millis(); |
| 773 | Serial.write(ch); |
| 774 | |
| 775 | if (ch == '\n' || ch == '\r') { |
| 776 | if (c.len > 0) { |
| 777 | // Hand over one whole command, with this client marked active |
| 778 | // so its replies come back to it and not to some other session. |
| 779 | ActiveClient = &c.sock; |
| 780 | for (int k = 0; k < c.len; k++) CmdMgr.handleChar(c.buf[k]); |
| 781 | CmdMgr.handleChar('\n'); |
| 782 | ActiveClient = NULL; |
| 783 | c.len = 0; |
| 784 | } |
| 785 | } else if (c.len < CLIENT_BUF_LEN - 1) { |
| 786 | c.buf[c.len++] = ch; |
| 787 | } else { |
| 788 | // Overlong line: discard it rather than dispatch a truncated |
| 789 | // command that would be parsed as something else. |
| 790 | c.len = 0; |
| 791 | Serial.printf("Client %d: line too long, discarded\n", i); |
| 792 | } |
| 793 | } |
| 794 | |
| 715 | 795 | // Idle-timeout: connected() can lie after FIN. Drop stale sessions. |
| 716 | | if (millis() - lastClientByteMs > CLIENT_IDLE_TIMEOUT_MS) { |
| 717 | | Serial.println("Client idle — dropping"); |
| 718 | | LastClient.stop(); |
| 719 | | LastClient = WiFiClient(); |
| 796 | if (millis() - c.lastByteMs > CLIENT_IDLE_TIMEOUT_MS) { |
| 797 | Serial.printf("Client %d idle — dropping\n", i); |
| 798 | c.sock.stop(); |
| 799 | c.sock = WiFiClient(); |
| 800 | c.len = 0; |
| 720 | 801 | } |
| 721 | | } else if (LastClient) { |
| 722 | | LastClient.stop(); |
| 723 | | LastClient = WiFiClient(); |
| 724 | 802 | } |
| 725 | 803 | } |
| 726 | 804 | break; |