diff --git a/.gitignore b/.gitignore index 1784fe7e..02595cce 100644 --- a/.gitignore +++ b/.gitignore @@ -45,5 +45,9 @@ node_modules/ __pycache__/ *.pyc + +# Firmware related: +firmware/panorama/.cache/ + # Runtime directory rundir/ \ No newline at end of file diff --git a/client/include/client/argparser.hpp b/client/include/client/argparser.hpp index 98b0b0fc..ff3eed31 100644 --- a/client/include/client/argparser.hpp +++ b/client/include/client/argparser.hpp @@ -8,10 +8,12 @@ class ArgParser { bool isTestMode() const; bool isNoGuiMode() const; + bool isNoEspMode() const; std::string getRuntimeDirectory() const; private: bool testMode_; bool noGuiMode_; + bool noEspMode_; std::string runtimeDir_; }; diff --git a/client/include/client/esp32_scanner.hpp b/client/include/client/esp32_scanner.hpp new file mode 100644 index 00000000..b7ed7046 --- /dev/null +++ b/client/include/client/esp32_scanner.hpp @@ -0,0 +1,35 @@ +#pragma once + +#include +#include +#include +#include + +class Esp32Scanner { +public: + static constexpr const char* ESP32_DEFAULT_HOST = "192.168.4.1"; + static constexpr int ESP32_DEFAULT_PORT = 9000; + static constexpr int SCAN_INTERVAL_MS = 5000; + static constexpr int CONNECT_TIMEOUT_MS = 500; + + Esp32Scanner(); + ~Esp32Scanner(); + + void start(); + void stop(); + + bool isAvailable() const { return available_.load(); } + + // Callback is fired on the scanner thread when the availability changes. + // The bool parameter is true when the ESP32 becomes reachable. + void setOnAvailabilityChanged(std::function cb); + +private: + void run(); + bool probe(const std::string& host, int port); + + std::atomic running_{false}; + std::atomic available_{false}; + std::thread thread_; + std::function callback_; +}; diff --git a/client/include/client/graph_panel.hpp b/client/include/client/graph_panel.hpp index e29f9bcd..e49f20eb 100644 --- a/client/include/client/graph_panel.hpp +++ b/client/include/client/graph_panel.hpp @@ -22,5 +22,12 @@ class GraphPanel : public wxPanel { std::set visibleSensors_; + void OnPaint(wxPaintEvent& event); + void OnSize(wxSizeEvent& event); + void DrawBackground(wxDC& dc); + void DrawGrid(wxDC& dc); + void DrawAxes(wxDC& dc); void UpdateGraph(); + + wxDECLARE_EVENT_TABLE(); }; \ No newline at end of file diff --git a/client/include/client/mainframe.hpp b/client/include/client/mainframe.hpp index d5e74de3..249c7b99 100644 --- a/client/include/client/mainframe.hpp +++ b/client/include/client/mainframe.hpp @@ -18,12 +18,11 @@ #include "client/graph_panel.hpp" #include - class MessageModel; class DataBuffer; -//class GraphPanel; +class TcpClient; +class Esp32Scanner; class SensorDataManager; -//class SensorDataFrame; // Add this class MainFrame : public wxFrame { public: @@ -37,11 +36,17 @@ class MainFrame : public wxFrame { ID_EDIT_PREFERENCES, ID_VIEW_CONSOLE, ID_VIEW_FULLSCREEN, - ID_SETTINGS_OPEN + ID_SETTINGS_OPEN, + ID_BTN_START, + ID_BTN_STOP, + ID_ESP32_AUTOSTART, + ID_ESP32_CONNECT, + ID_ESP32_DISMISS }; MainFrame(const wxString& title, std::shared_ptr model, std::shared_ptr dataBuffer, + TcpClient* tcpClient, const wxPoint& pos = wxDefaultPosition, const wxSize& size = wxSize(1200, 800)); @@ -65,9 +70,16 @@ class MainFrame : public wxFrame { void OnViewConsole(wxCommandEvent& event); void OnViewFullscreen(wxCommandEvent& event); void OnSettingsOpen(wxCommandEvent& event); + void OnStartStream(wxCommandEvent& event); + void OnStopStream(wxCommandEvent& event); + void OnEsp32Autostart(wxCommandEvent& event); + void OnEsp32Connect(wxCommandEvent& event); + void OnEsp32Dismiss(wxCommandEvent& event); + void ShowEsp32Banner(bool show); std::shared_ptr model_; std::shared_ptr dataBuffer_; + TcpClient* tcpClient_; wxTextCtrl* messageDisplay_; wxPanel* consolePanel_; GraphPanel* graphPanel_; @@ -79,7 +91,12 @@ class MainFrame : public wxFrame { void OnUpdateTimer(wxTimerEvent& event); - + // Auto detecting ESP32s + std::unique_ptr esp32Scanner_; + wxPanel* esp32Banner_ = nullptr; + wxBoxSizer* mainSizer_ = nullptr; + std::atomic esp32BannerPending_{false}; + bool esp32BannerVisible_ = false; }; #endif // __MAINFRAME__ \ No newline at end of file diff --git a/client/include/client/tcp_client.hpp b/client/include/client/tcp_client.hpp index 26b72557..e6b6a744 100644 --- a/client/include/client/tcp_client.hpp +++ b/client/include/client/tcp_client.hpp @@ -17,6 +17,8 @@ class TcpClient { void start(); void stop(); + void reconnectWith(const std::string& host, int port); + void sendCommand(const std::string& cmd); private: void run(); diff --git a/client/include/common/panorama_colours.hpp b/client/include/common/panorama_colours.hpp new file mode 100644 index 00000000..db4de32d --- /dev/null +++ b/client/include/common/panorama_colours.hpp @@ -0,0 +1,21 @@ +#pragma once + +#include + +// Panorama colour palette +const wxColour PCOLOUR_WHITE(255, 255, 255); +const wxColour PCOLOUR_BLACK(0, 0, 0); + +const wxColour PCOLOUR_DARK_GREY(40, 40, 40); +const wxColour PCOLOUR_GREY(90, 90, 90); +const wxColour PCOLOUR_MID_GREY(150, 150, 150); +const wxColour PCOLOUR_LIGHT_GREY(220, 220, 220); +const wxColour PCOLOUR_PANEL_GREY(240, 240, 240); + +const wxColour PCOLOUR_GREEN(76, 175, 80); +const wxColour PCOLOUR_RED(244, 67, 54); +const wxColour PCOLOUR_BLUE(33, 150, 243); + +const wxColour PCOLOUR_LIGHT_RED(255, 200, 200); +const wxColour PCOLOUR_LIGHT_YELLOW(255, 255, 200); +const wxColour PCOLOUR_LIGHT_GREEN(200, 255, 200); diff --git a/client/src/argparser.cpp b/client/src/argparser.cpp index aaeaaa98..d1a5ef2c 100644 --- a/client/src/argparser.cpp +++ b/client/src/argparser.cpp @@ -2,16 +2,18 @@ #include "wx/cmdline.h" ArgParser::ArgParser(int argc, char** argv) - : testMode_(false), noGuiMode_(false), runtimeDir_("") + : testMode_(false), noGuiMode_(false), noEspMode_(false), runtimeDir_("") { wxCmdLineParser parser(argc, argv); parser.AddSwitch("t", "test", "test mode"); parser.AddSwitch("n", "nogui", "run without GUI (console mode)"); + parser.AddSwitch("e", "noesp", "run without ESP32 (use localhost TCP)"); parser.AddOption("r", "runtime-dir", "runtime directory for data and config", wxCMD_LINE_VAL_STRING, wxCMD_LINE_PARAM_OPTIONAL); parser.Parse(false); // don't exit on errors testMode_ = parser.Found("test"); noGuiMode_ = parser.Found("nogui"); + noEspMode_ = parser.Found("noesp"); wxString runtimeDirWx; if (parser.Found("runtime-dir", &runtimeDirWx)) { @@ -27,6 +29,10 @@ bool ArgParser::isNoGuiMode() const { return noGuiMode_; } +bool ArgParser::isNoEspMode() const { + return noEspMode_; +} + std::string ArgParser::getRuntimeDirectory() const { return runtimeDir_; } \ No newline at end of file diff --git a/client/src/esp32_scanner.cpp b/client/src/esp32_scanner.cpp new file mode 100644 index 00000000..d50139f2 --- /dev/null +++ b/client/src/esp32_scanner.cpp @@ -0,0 +1,126 @@ +#include "client/esp32_scanner.hpp" +#include + +#ifdef _WIN32 + #include + #include + #define CLOSE_SOCKET closesocket +#else + #include + #include + #include + #include + #include + #include + #include + #define CLOSE_SOCKET close + #define INVALID_SOCKET -1 +#endif + +Esp32Scanner::Esp32Scanner() {} + +Esp32Scanner::~Esp32Scanner() { + stop(); +} + +void Esp32Scanner::start() { + if (running_.load()) return; + running_ = true; + thread_ = std::thread(&Esp32Scanner::run, this); +} + +void Esp32Scanner::stop() { + running_ = false; + if (thread_.joinable()) { + thread_.join(); + } +} + +void Esp32Scanner::setOnAvailabilityChanged(std::function cb) { + callback_ = std::move(cb); +} + +void Esp32Scanner::run() { + while (running_) { + bool reachable = probe(ESP32_DEFAULT_HOST, ESP32_DEFAULT_PORT); + bool prev = available_.exchange(reachable); + + if (reachable != prev && callback_) { + callback_(reachable); + } + + for (int i = 0; i < SCAN_INTERVAL_MS / 100 && running_; ++i) { + std::this_thread::sleep_for(std::chrono::milliseconds(100)); // prevent stop() blocking for too long + } + } +} + +bool Esp32Scanner::probe(const std::string& host, int port) { +#ifdef _WIN32 + SOCKET sock = socket(AF_INET, SOCK_STREAM, IPPROTO_TCP); + if (sock == INVALID_SOCKET) return false; + + // Set this as non-blocking + u_long mode = 1; + ioctlsocket(sock, FIONBIO, &mode); + + sockaddr_in addr{}; + addr.sin_family = AF_INET; + addr.sin_port = htons(port); + addr.sin_addr.s_addr = inet_addr(host.c_str()); + + connect(sock, (sockaddr*)&addr, sizeof(addr)); + + fd_set writefds; + FD_ZERO(&writefds); + FD_SET(sock, &writefds); + timeval tv; + tv.tv_sec = 0; + tv.tv_usec = CONNECT_TIMEOUT_MS * 1000; + + int result = select(0, nullptr, &writefds, nullptr, &tv); + closesocket(sock); + return result > 0; +#else + int sock = socket(AF_INET, SOCK_STREAM, IPPROTO_TCP); + if (sock == INVALID_SOCKET) return false; + + // Set this as non-blocking + int flags = fcntl(sock, F_GETFL, 0); + fcntl(sock, F_SETFL, flags | O_NONBLOCK); + + sockaddr_in addr{}; + addr.sin_family = AF_INET; + addr.sin_port = htons(port); + inet_pton(AF_INET, host.c_str(), &addr.sin_addr); + + int ret = connect(sock, (sockaddr*)&addr, sizeof(addr)); + if (ret == 0) { + // Connected immediately + CLOSE_SOCKET(sock); + return true; + } + + if (errno != EINPROGRESS) { + CLOSE_SOCKET(sock); + return false; + } + + // Wait for connection, with timeout + struct pollfd pfd; + pfd.fd = sock; + pfd.events = POLLOUT; + int pollResult = poll(&pfd, 1, CONNECT_TIMEOUT_MS); + + bool connected = false; + if (pollResult > 0 && (pfd.revents & POLLOUT)) { + int err = 0; + socklen_t len = sizeof(err); + getsockopt(sock, SOL_SOCKET, SO_ERROR, &err, &len); + connected = (err == 0); + } + + CLOSE_SOCKET(sock); + return connected; +#endif +} diff --git a/client/src/graph_panel.cpp b/client/src/graph_panel.cpp index 9ee7bf25..82153b91 100644 --- a/client/src/graph_panel.cpp +++ b/client/src/graph_panel.cpp @@ -1,4 +1,13 @@ #include "client/graph_panel.hpp" +#include "common/panorama_colours.hpp" +#include +#include +#include + +wxBEGIN_EVENT_TABLE(GraphPanel, wxPanel) + EVT_PAINT(GraphPanel::OnPaint) + EVT_SIZE(GraphPanel::OnSize) +wxEND_EVENT_TABLE() GraphPanel::GraphPanel(wxWindow* parent) : wxPanel(parent, wxID_ANY) { @@ -23,6 +32,101 @@ GraphPanel::GraphPanel(wxWindow* parent) wxBoxSizer* sizer = new wxBoxSizer(wxVERTICAL); sizer->Add(m_plot, 1, wxEXPAND); SetSizer(sizer); + SetBackgroundColour(PCOLOUR_WHITE); + SetBackgroundStyle(wxBG_STYLE_PAINT); // double buffering so smoother +} + +// redraw the panel +void GraphPanel::OnPaint(wxPaintEvent& event) { + wxAutoBufferedPaintDC dc(this); + dc.Clear(); + + DrawBackground(dc); + DrawGrid(dc); + DrawAxes(dc); +} + +// Function: used when the panel is resized to redraw +void GraphPanel::OnSize(wxSizeEvent& event) { + Refresh(); + Update(); + + event.Skip(); +} + +// Drawing functions +void GraphPanel::DrawBackground(wxDC& dc) { + wxSize size = GetClientSize(); + + dc.SetBrush(wxBrush(PCOLOUR_LIGHT_GREY)); + + dc.SetPen(*wxTRANSPARENT_PEN); + + dc.DrawRectangle(0, 0, size.GetWidth(), size.GetHeight()); +} + +// Function: to draw grid lines +void GraphPanel::DrawGrid(wxDC& dc) { + wxSize size = GetClientSize(); + + int leftMargin = 60; + int bottomMargin = 40; + int topMargin = 20; + int graphHeight = size.GetHeight() - topMargin - bottomMargin; + int graphWidth = size.GetWidth() - leftMargin - 20; + + dc.SetPen(wxPen(PCOLOUR_GREY, 1)); + + // horizontal grid lines + for (int i = 0; i <= 4; i++) { + int y = topMargin + (graphHeight * i / 4); + dc.DrawLine(leftMargin, y, leftMargin + graphWidth, y); + } + + // vertical grid lines + for (int i = 0; i <=6; i++) { + int x = leftMargin + (graphWidth * i / 6); + dc.DrawLine(x, topMargin, x, topMargin + graphHeight); + } +} + + +void GraphPanel::DrawAxes(wxDC& dc){ + wxSize size = GetClientSize(); + + int leftMargin = 60; + int bottomMargin = 40; + int topMargin = 20; + int graphHeight = size.GetHeight() - topMargin - bottomMargin; + int graphWidth = size.GetWidth() - leftMargin - 20; + + dc.SetPen(wxPen(PCOLOUR_BLACK, 2)); + + // draw y-axis + dc.DrawLine(leftMargin, topMargin, leftMargin, topMargin + graphHeight); + + // draw x-axis + dc.DrawLine(leftMargin, topMargin + graphHeight, leftMargin + graphWidth, topMargin + graphHeight); + + // + //x axis label + for (int i = 0; i < 6; i++) { + int x = leftMargin + (graphWidth * i / 6); + int value = i; + wxString label = wxString::Format("%d", value); + wxSize textSize = dc.GetTextExtent(label); + dc.DrawText(label, x - textSize.GetWidth() / 2, size.GetHeight() - bottomMargin + 5); + } + + //y axis label + for (int i = 0; i <= 4; i++) { + int y = (topMargin + graphHeight) - (graphHeight * i / 4); + int value = i * 25; + wxString label = wxString::Format("%d", value); + wxSize textSize = dc.GetTextExtent(label); + dc.DrawText(label, leftMargin - textSize.GetWidth() - 5, y - textSize.GetHeight() / 2); + } + } void GraphPanel::AddDataPoint(const std::string& sensorName, double value, double timestamp){ diff --git a/client/src/json_reader.cpp b/client/src/json_reader.cpp index 2e84a19a..a1e79834 100644 --- a/client/src/json_reader.cpp +++ b/client/src/json_reader.cpp @@ -56,6 +56,16 @@ buffer_data_t JsonReader::exportToBuffer(std::string json) { return ret; } + // if (!doc.HasMember("sensor") || !doc["sensor"].IsString() || + // !doc.HasMember("unit") || !doc["unit"].IsString() || + // !doc.HasMember("value") || !doc["value"].IsNumber()) { + // return ret; + // } + + // ret.datatype = std::string(doc["sensor"].GetString(), doc["sensor"].GetStringLength()); + // ret.data = doc["value"].GetDouble(); + // ret.dataunit = std::string(doc["unit"].GetString(), doc["unit"].GetStringLength()); + // ret.timestamp = std::time(&ret.timestamp); ret.sensor = ""; ret.datatype = ""; ret.dataunit = ""; diff --git a/client/src/main.cpp b/client/src/main.cpp index 79ae4063..7f536bd6 100644 --- a/client/src/main.cpp +++ b/client/src/main.cpp @@ -136,7 +136,18 @@ class PanoramaClient : public wxApp { dataBuffer_ = std::make_shared(runtimeDir + "/data"); // --- Create and start TCP client on separate thread --- - tcpClient_ = std::make_unique("127.0.0.1", 3000, model_, dataBuffer_, dataLogger_); + std::string tcpHost; + int tcpPort; + bool autoReconnect; + int reconnectDelay; + if (parser.isNoEspMode()) { + tcpHost = "127.0.0.1"; + tcpPort = 3000; + } else if (!config.getTcpSettings(tcpHost, tcpPort, autoReconnect, reconnectDelay)) { + tcpHost = "127.0.0.1"; + tcpPort = 3000; + } + tcpClient_ = std::make_unique(tcpHost, tcpPort, model_, dataBuffer_, dataLogger_); tcpClient_->start(); // For running without a gui @@ -155,7 +166,7 @@ class PanoramaClient : public wxApp { jsonWriterThread_ = std::make_unique(&JsonWriter::start, jsonWriter_); // --- Create view --- - MainFrame* w = new MainFrame("Panorama Client", model_, dataBuffer_); + MainFrame* w = new MainFrame("Panorama Client", model_, dataBuffer_, tcpClient_.get()); w->Show(); diff --git a/client/src/mainframe.cpp b/client/src/mainframe.cpp index 520bc8aa..224c9e6d 100644 --- a/client/src/mainframe.cpp +++ b/client/src/mainframe.cpp @@ -7,20 +7,61 @@ #include "client/sensor.hpp" #include "client/settings_dialog.hpp" #include "common/panorama_utils.hpp" +#include "common/panorama_colours.hpp" +#include "client/tcp_client.hpp" +#include "client/config_manager.hpp" +#include "client/esp32_scanner.hpp" #include #include #include MainFrame::MainFrame(const wxString& title, std::shared_ptr model, std::shared_ptr dataBuffer, + TcpClient* tcpClient, const wxPoint& pos, const wxSize& size) - : wxFrame(nullptr, wxID_ANY, title, pos, size), model_(model), dataBuffer_(dataBuffer) { + : wxFrame(nullptr, wxID_ANY, title, pos, size), model_(model), dataBuffer_(dataBuffer), tcpClient_(tcpClient) { CreateMenuBar(); updateTimer_.Bind(wxEVT_TIMER, &MainFrame::OnUpdateTimer, this); updateTimer_.Start(10); // milliseconds between GUI refreshes + // Control bar with start and stop btns + wxPanel* controlBar = new wxPanel(this); + wxBoxSizer* controlSizer = new wxBoxSizer(wxHORIZONTAL); + wxButton* startBtn = new wxButton(controlBar, ID_BTN_START, wxString::FromUTF8("\u25B6 Start")); + startBtn->SetBackgroundColour(PCOLOUR_GREEN); + startBtn->SetForegroundColour(*wxWHITE); + wxButton* stopBtn = new wxButton(controlBar, ID_BTN_STOP, wxString::FromUTF8("\u25A0 Stop")); + stopBtn->SetBackgroundColour(PCOLOUR_RED); + stopBtn->SetForegroundColour(*wxWHITE); + controlSizer->Add(startBtn, 0, wxALL, 4); + controlSizer->Add(stopBtn, 0, wxALL, 4); + controlBar->SetSizer(controlSizer); + + Bind(wxEVT_BUTTON, &MainFrame::OnStartStream, this, ID_BTN_START); + Bind(wxEVT_BUTTON, &MainFrame::OnStopStream, this, ID_BTN_STOP); + + // ESP32 notification banner (hidden by default) + esp32Banner_ = new wxPanel(this); + esp32Banner_->SetBackgroundColour(PCOLOUR_BLUE); + wxBoxSizer* bannerSizer = new wxBoxSizer(wxHORIZONTAL); + wxStaticText* bannerLabel = new wxStaticText(esp32Banner_, wxID_ANY, + wxString::FromUTF8(" ESP32 device detected on network (192.168.4.1:9000)")); + bannerLabel->SetForegroundColour(*wxWHITE); + wxButton* autostartBtn = new wxButton(esp32Banner_, ID_ESP32_AUTOSTART, "Connect && Autostart"); + wxButton* connectBtn = new wxButton(esp32Banner_, ID_ESP32_CONNECT, "Connect"); + wxButton* dismissBtn = new wxButton(esp32Banner_, ID_ESP32_DISMISS, "Dismiss"); + bannerSizer->Add(bannerLabel, 1, wxALIGN_CENTER_VERTICAL | wxLEFT, 5); + bannerSizer->Add(autostartBtn, 0, wxALL, 3); + bannerSizer->Add(connectBtn, 0, wxALL, 3); + bannerSizer->Add(dismissBtn, 0, wxALL, 3); + esp32Banner_->SetSizer(bannerSizer); + esp32Banner_->Hide(); + + Bind(wxEVT_BUTTON, &MainFrame::OnEsp32Autostart, this, ID_ESP32_AUTOSTART); + Bind(wxEVT_BUTTON, &MainFrame::OnEsp32Connect, this, ID_ESP32_CONNECT); + Bind(wxEVT_BUTTON, &MainFrame::OnEsp32Dismiss, this, ID_ESP32_DISMISS); // Create splitter for layout wxSplitterWindow* mainSplitter = new wxSplitterWindow(this, wxID_ANY); @@ -32,7 +73,7 @@ MainFrame::MainFrame(const wxString& title, std::shared_ptr model, // Data view area - Sensor Data Panel (rows added dynamically as sensors arrive) wxPanel* dataViewPanel = new wxPanel(rightSplitter); - dataViewPanel->SetBackgroundColour(wxColour(240, 240, 240)); + dataViewPanel->SetBackgroundColour(PCOLOUR_PANEL_GREY); sensorDataGrid = new SensorDataFrame(dataViewPanel, wxArrayString()); @@ -50,8 +91,8 @@ MainFrame::MainFrame(const wxString& title, std::shared_ptr model, messageDisplay_ = new wxTextCtrl(consolePanel_, wxID_ANY, "", wxDefaultPosition, wxDefaultSize, wxTE_MULTILINE | wxTE_READONLY | wxTE_WORDWRAP); - messageDisplay_->SetBackgroundColour(wxColour(40, 40, 40)); - messageDisplay_->SetForegroundColour(wxColour(255, 255, 255)); + messageDisplay_->SetBackgroundColour(PCOLOUR_DARK_GREY); + messageDisplay_->SetForegroundColour(PCOLOUR_WHITE); wxStaticText* consoleLabel = new wxStaticText(consolePanel_, wxID_ANY, "Console"); @@ -75,9 +116,11 @@ MainFrame::MainFrame(const wxString& title, std::shared_ptr model, mainSplitter->SetSashGravity(1.0); // keeps console constant at 150px // Layout - wxBoxSizer* mainSizer = new wxBoxSizer(wxVERTICAL); - mainSizer->Add(mainSplitter, 1, wxEXPAND); - SetSizer(mainSizer); + mainSizer_ = new wxBoxSizer(wxVERTICAL); + mainSizer_->Add(controlBar, 0, wxEXPAND); + mainSizer_->Add(esp32Banner_, 0, wxEXPAND); + mainSizer_->Add(mainSplitter, 1, wxEXPAND); + SetSizer(mainSizer_); // Register as observer model_->addObserver(std::bind(&MainFrame::onModelUpdated, this)); @@ -86,6 +129,15 @@ MainFrame::MainFrame(const wxString& title, std::shared_ptr model, sensorManager_->SetOnSensorToggled(std::bind(&MainFrame::onSensorToggled, this)); CreateStatusBar(); + + // Start ESP32 auto-detection scanner + esp32Scanner_ = std::make_unique(); + esp32Scanner_->setOnAvailabilityChanged([this](bool available) { + if (available) { + esp32BannerPending_.store(true); + } + }); + esp32Scanner_->start(); } void MainFrame::onModelUpdated() { @@ -254,7 +306,28 @@ void MainFrame::OnViewFullscreen(wxCommandEvent& event) { void MainFrame::OnSettingsOpen(wxCommandEvent& event) { SettingsDialog dialog(this); - dialog.ShowModal(); + if (dialog.ShowModal() == wxID_OK && tcpClient_) { + ConfigManager& config = ConfigManager::getInstance(); + std::string host; + int port; + bool autoReconnect; + int reconnectDelay; + if (config.getTcpSettings(host, port, autoReconnect, reconnectDelay)) { + tcpClient_->reconnectWith(host, port); + } + } +} + +void MainFrame::OnStartStream(wxCommandEvent& event) { + if (tcpClient_) { + tcpClient_->sendCommand("START"); + } +} + +void MainFrame::OnStopStream(wxCommandEvent& event) { + if (tcpClient_) { + tcpClient_->sendCommand("STOP"); + } } void MainFrame::OnUpdateTimer(wxTimerEvent&) { @@ -262,4 +335,48 @@ void MainFrame::OnUpdateTimer(wxTimerEvent&) { updateMessageDisplay(); updateDataPanel(); } + + // Makesure the esp is actually detected + if (esp32BannerPending_.exchange(false)) { + ShowEsp32Banner(true); + } } + +void MainFrame::ShowEsp32Banner(bool show) { + if (esp32BannerVisible_ == show) return; + esp32BannerVisible_ = show; + esp32Banner_->Show(show); + mainSizer_->Layout(); +} + +void MainFrame::OnEsp32Autostart(wxCommandEvent& event) { + if (tcpClient_) { + tcpClient_->reconnectWith( + Esp32Scanner::ESP32_DEFAULT_HOST, + Esp32Scanner::ESP32_DEFAULT_PORT); + model_->addMessage("Connecting to ESP32 at 192.168.4.1:9000 (autostart)..."); + tcpClient_->sendCommand("START"); + } + ShowEsp32Banner(false); + if (esp32Scanner_) { + esp32Scanner_->stop(); + } +} + +void MainFrame::OnEsp32Connect(wxCommandEvent& event) { + if (tcpClient_) { + tcpClient_->reconnectWith( + Esp32Scanner::ESP32_DEFAULT_HOST, + Esp32Scanner::ESP32_DEFAULT_PORT); + model_->addMessage("Connecting to ESP32 at 192.168.4.1:9000..."); + } + ShowEsp32Banner(false); + + if (esp32Scanner_) { + esp32Scanner_->stop(); + } +} + +void MainFrame::OnEsp32Dismiss(wxCommandEvent& event) { + ShowEsp32Banner(false); +} \ No newline at end of file diff --git a/client/src/sensor_data_panel.cpp b/client/src/sensor_data_panel.cpp index acc9250c..b78303c7 100644 --- a/client/src/sensor_data_panel.cpp +++ b/client/src/sensor_data_panel.cpp @@ -1,4 +1,5 @@ #include "client/sensor_data_panel.h" +#include "common/panorama_colours.hpp" #include SensorDataFrame::SensorDataFrame(wxWindow* parent, const wxArrayString& sensorNames) @@ -111,11 +112,11 @@ void SensorDataFrame::UpdateReading(const std::string& sensorName, double value, // Color if (value > 50.0) { - grid_->SetCellBackgroundColour(row, 1, wxColour(255, 200, 200)); // Light red + grid_->SetCellBackgroundColour(row, 1, PCOLOUR_LIGHT_RED); } else if (value > 25.0) { - grid_->SetCellBackgroundColour(row, 1, wxColour(255, 255, 200)); // Light yellow + grid_->SetCellBackgroundColour(row, 1, PCOLOUR_LIGHT_YELLOW); } else { - grid_->SetCellBackgroundColour(row, 1, wxColour(200, 255, 200)); // Light green + grid_->SetCellBackgroundColour(row, 1, PCOLOUR_LIGHT_GREEN); } grid_->ForceRefresh(); diff --git a/client/src/sensor_manager.cpp b/client/src/sensor_manager.cpp index 85ec05e6..b712aa13 100644 --- a/client/src/sensor_manager.cpp +++ b/client/src/sensor_manager.cpp @@ -1,11 +1,12 @@ #include #include #include "client/settings_dialog.hpp" +#include "common/panorama_colours.hpp" SensorManagerPanel::SensorManagerPanel(wxWindow* parent) : wxPanel(parent, wxID_ANY) { - SetBackgroundColour(wxColour(150, 150, 150)); + SetBackgroundColour(PCOLOUR_MID_GREY); sizer_ = new wxBoxSizer(wxVERTICAL); diff --git a/client/src/settings_dialog.cpp b/client/src/settings_dialog.cpp index f7893917..bb732643 100644 --- a/client/src/settings_dialog.cpp +++ b/client/src/settings_dialog.cpp @@ -5,7 +5,7 @@ #include SettingsDialog::SettingsDialog(wxWindow* parent) - : wxDialog(parent, wxID_ANY, "Settings", wxDefaultPosition, wxSize(500, 300)) { + : wxDialog(parent, wxID_ANY, "Settings", wxDefaultPosition, wxSize(500, 400)) { wxBoxSizer* mainSizer = new wxBoxSizer(wxVERTICAL); @@ -118,7 +118,7 @@ void SettingsDialog::saveSettings() { void SettingsDialog::OnSave(wxCommandEvent& event) { saveSettings(); - wxMessageBox("Settings saved successfully.\n\nNote: Some settings may require an application restart to take effect.", + wxMessageBox("Settings saved successfully.\n\nTCP connection settings will take effect immediately.", "Settings Saved", wxOK | wxICON_INFORMATION); EndModal(wxID_OK); } diff --git a/client/src/tcp_client.cpp b/client/src/tcp_client.cpp index df5d45c4..039c39a6 100644 --- a/client/src/tcp_client.cpp +++ b/client/src/tcp_client.cpp @@ -92,11 +92,6 @@ void TcpClient::run() { logger_->logJsonData(received); } - // print json - //pinfo("Received JSON: ", received); - reader.exportToBuffer(received); - - // Parse JSON and write to DataBuffer buffer_data_t parsedData = reader.exportToBuffer(received); dataBuffer_->writeData(parsedData); @@ -106,6 +101,23 @@ void TcpClient::run() { } } +void TcpClient::sendCommand(const std::string& cmd) { + if (socket_ != INVALID_SOCKET) { + std::string msg = cmd + "\n"; + send(socket_, msg.c_str(), msg.size(), 0); + model_->addMessage("Sent command: " + cmd); + } else { + model_->addMessage("Error: Not connected, cannot send command"); + } +} + +void TcpClient::reconnectWith(const std::string& host, int port) { + host_ = host; + port_ = port; + // Force a disconnectso the run loop reconnects with new settings + cleanup(); +} + void TcpClient::reconnect() { cleanup(); diff --git a/firmware/panorama/.clangd b/firmware/panorama/.clangd new file mode 100644 index 00000000..e3c57092 --- /dev/null +++ b/firmware/panorama/.clangd @@ -0,0 +1,9 @@ +# Strip ESP32/toolchain flags that clang (x86 host) doesn't support when used for IDE diagnostics. +# Use headers from the compiler in compile_commands.json so stdlib.h and other toolchain headers are found. +CompileFlags: + BuiltinHeaders: QueryDriver + Remove: + - -mlongcalls + - -mlong-calls + - -fstrict-volatile-bitfields + - -fno-tree-switch-conversion diff --git a/firmware/panorama/src/main.cpp b/firmware/panorama/src/main.cpp deleted file mode 100644 index c227be3c..00000000 --- a/firmware/panorama/src/main.cpp +++ /dev/null @@ -1,106 +0,0 @@ -#include -#include - -// ===== Wi-Fi Access Point (ESP32 hosts itself) ===== -const char* AP_SSID = "ESP32-JSON"; -const char* AP_PASS = "esp32json"; // must be 8+ chars - -// ===== TCP server ===== -const uint16_t SERVER_PORT = 9000; -WiFiServer server(SERVER_PORT); - -// ===== State ===== -WiFiClient client; // single client for simplicity -unsigned long lastSendMs = 0; // pacing JSON sends -const uint32_t SEND_PERIOD_MS = 1000; - -// make a tiny JSON line without ArduinoJson -String makeJson() { - // dummy data — tweak as you like - static int seq = 0; - float temp = 20.0 + sin(millis() / 1000.0) * 2.5; - int humidity = 40 + (millis()/1000) % 20; - - // ISO-ish time in ms since boot for now - String s = "{"; - s += "\"seq\":" + String(seq++) + ","; - s += "\"ts_ms\":" + String(millis()) + ","; - s += "\"temp_c\":" + String(temp, 2) + ","; - s += "\"humidity\":" + String(humidity) + ","; - s += "\"status\":\"ok\""; - s += "}\n"; // newline-delimited JSON (NDJSON) - return s; -} - -void setup() { - Serial.begin(115200); - delay(200); - - // Start AP (self-hosted) - Serial.println("[WiFi] Starting AP…"); - bool ok = WiFi.softAP(AP_SSID, AP_PASS); - if (!ok) { - Serial.println("[WiFi] AP start failed!"); - } - IPAddress ip = WiFi.softAPIP(); // usually 192.168.4.1 - Serial.print("[WiFi] AP SSID: "); Serial.println(AP_SSID); - Serial.print("[WiFi] AP PASS: "); Serial.println(AP_PASS); - Serial.print("[WiFi] AP IP: "); Serial.println(ip); - - // Start TCP server - server.begin(); - server.setNoDelay(true); - Serial.print("[TCP] Listening on port "); Serial.println(SERVER_PORT); -} - -void handleNewClient() { - WiFiClient incoming = server.available(); - if (!incoming) return; - - // If we already have a client, drop the older one - if (client && client.connected()) { - client.stop(); - } - - client = incoming; - client.setTimeout(50); - Serial.print("[TCP] Client connected from "); - Serial.println(client.remoteIP()); - - // optional greeting / one-shot JSON blob - client.print("{\"hello\":\"welcome\",\"port\":"); - client.print(SERVER_PORT); - client.print(",\"hint\":\"I will stream one JSON per second. Each line is a JSON object.\"}\n"); -} - -void streamJsonIfTime() { - if (!client || !client.connected()) return; - - // read & ignore any input (you could add commands here) - while (client.available()) { - (void)client.read(); // drain input - } - - unsigned long now = millis(); - if (now - lastSendMs >= SEND_PERIOD_MS) { - lastSendMs = now; - String line = makeJson(); - client.print(line); // send one JSON line - Serial.print("[TX] "); // mirror to serial - Serial.print(line); - } -} - -void loop() { - // accept new client connections - handleNewClient(); - - // send JSON periodically if a client is connected - streamJsonIfTime(); - - // clean up if disconnected - if (client && !client.connected()) { - Serial.println("[TCP] Client disconnected"); - client.stop(); - } -} diff --git a/firmware/panorama/src/test.cpp b/firmware/panorama/src/test.cpp new file mode 100644 index 00000000..e2d9e8b5 --- /dev/null +++ b/firmware/panorama/src/test.cpp @@ -0,0 +1,145 @@ +#include +#include + +// Wi-Fi Access Point credentials +const char* SSID = "ESP32-Interface"; +const char* PASS = "12345678"; +const uint16_t PORT = 9000; + +// Onboard LED pin +const int LED_PIN = 2; + +// HC-SR04 pins (adjust to match your wiring) +const int TRIG_PIN = 5; +const int ECHO_PIN = 18; +const unsigned long BLINK_DELAY_MS = 250; + +WiFiServer server(PORT); +WiFiClient client; + +bool sendEnabled = false; +unsigned long sampleInterval = 1000; // ms +unsigned long lastSend = 0; +unsigned long startTime = 0; +unsigned long lastBlink = 0; +bool ledState = false; + +uint32_t seq = 0; + +struct SensorConfig { + const char* sensor; + const char* dataunit; + const char* datatype; + uint16_t sensorID; + float baseValue; + float variation; +}; + +const SensorConfig sensors[] = { + {"TM1000", "K", "temperature", 1, 25.5f, 0.5f}, + {"HD1000", "watercontent", "humidity", 2, 60.2f, 0.3f}, + {"PP1000", "hPa", "pressure", 3, 1013.25f, 0.1f}, + {"NM1000", "nm", "light", 4, 0.2f, 0.0f}, +}; +const int NUM_SENSORS = sizeof(sensors) / sizeof(sensors[0]); + +void setup() { + Serial.begin(115200); + + // onboard LED + pinMode(LED_PIN, OUTPUT); + digitalWrite(LED_PIN, LOW); + + // ultrasonic sensor pins + pinMode(TRIG_PIN, OUTPUT); + pinMode(ECHO_PIN, INPUT); + digitalWrite(TRIG_PIN, LOW); + + WiFi.mode(WIFI_AP); + WiFi.softAP(SSID, PASS); + IPAddress ip = WiFi.softAPIP(); + Serial.printf("AP started: %s (%s)\n", SSID, ip.toString().c_str()); + + server.begin(); + server.setNoDelay(true); +} + +void handleCommand(String cmd) { + cmd.trim(); + cmd.toUpperCase(); + + if (cmd.startsWith("START")) { + int spaceIdx = cmd.indexOf(' '); + if (spaceIdx > 0) { + float freq = cmd.substring(spaceIdx + 1).toFloat(); + if (freq > 0) sampleInterval = 1000.0 / freq; + } + startTime = millis(); + seq = 0; // reset sequence on start + sendEnabled = true; + Serial.printf("Signal ON, freq=%.1f Hz\n", 1000.0 / sampleInterval); + + } else if (cmd.startsWith("STOP")) { + sendEnabled = false; + Serial.println("Signal OFF"); + + } else { + Serial.printf("Unknown cmd: %s\n", cmd.c_str()); + } +} + +void loop() { + // accept new client + WiFiClient newClient = server.available(); + if (newClient) { + if (client && client.connected()) client.stop(); + client = newClient; + client.print(F("{\"type\":\"status\",\"msg\":\"connected\"}\n")); + Serial.println("Backend connected"); + } + + // update onboard LED: solid when connected, blink when disconnected + if (client && client.connected()) { + digitalWrite(LED_PIN, HIGH); + } else { + unsigned long now = millis(); + if (now - lastBlink >= BLINK_DELAY_MS) { + lastBlink = now; + ledState = !ledState; + digitalWrite(LED_PIN, ledState ? HIGH : LOW); + } + } + + // read commands + if (client && client.connected() && client.available()) { + String cmd = client.readStringUntil('\n'); + handleCommand(cmd); + } + + if (sendEnabled && client && client.connected()) { + unsigned long now = millis(); + if (now - lastSend >= sampleInterval) { + lastSend = now; + + int idx = seq % NUM_SENSORS; + const SensorConfig& s = sensors[idx]; + float value = s.baseValue + (seq % 10) * s.variation; + unsigned long timestamp = now - startTime; + + String json = + "{" + "\"sensor\":\"" + String(s.sensor) + "\"," + "\"dataunit\":\"" + String(s.dataunit) + "\"," + "\"data\":" + String(value, 2) + "," + "\"datatype\":\"" + String(s.datatype) + "\"," + "\"sensorID\":" + String(s.sensorID) + "," + "\"seq\":" + String(seq++) + "," + "\"timestamp\":" + String(timestamp) + + "}\n"; + + client.print(json); + Serial.print("Sent: "); + Serial.print(json); + } + } +} \ No newline at end of file diff --git a/firmware/panorama/src/test_server.py b/firmware/panorama/src/test_server.py new file mode 100644 index 00000000..e77bfa14 --- /dev/null +++ b/firmware/panorama/src/test_server.py @@ -0,0 +1,98 @@ +import socket +import sys +import json +import threading + +ESP_IP = "192.168.4.1" # ESP32 softAP IP (printed in Serial Monitor) +PORT = 9000 + +# Commands the ESP32 understands (sent as one line, newline-terminated): +# START [freq] - start streaming at freq Hz (default 5); e.g. "START 10" +# STOP - stop streaming +# Type QUIT or EXIT to close the connection and exit. + + +def read_commands(sock, stop_event): + """Read lines from stdin and send them as commands to the ESP32.""" + try: + while not stop_event.is_set(): + line = sys.stdin.readline() + if not line: + break + line = line.strip() + if not line: + continue + if line.upper() in ("QUIT", "EXIT", "Q"): + stop_event.set() + break + # Send command to ESP32 (one line, newline-terminated) + try: + sock.sendall((line + "\n").encode()) + print(f"[sent] {line}") + except OSError as e: + print(f"[error] send failed: {e}", file=sys.stderr) + stop_event.set() + break + except (KeyboardInterrupt, EOFError): + stop_event.set() + + +def main(): + with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s: + s.connect((ESP_IP, PORT)) + print(f"Connected to {ESP_IP}:{PORT}") + print("Send commands: START [freq], STOP. Type QUIT to exit.\n") + + # Read optional status line from ESP32 + s.settimeout(0.5) + try: + status = s.recv(1024).decode().strip() + if status: + print("Status from ESP32:", status) + except socket.timeout: + pass + except OSError: + pass + s.settimeout(None) + + stop_event = threading.Event() + cmd_thread = threading.Thread(target=read_commands, args=(s, stop_event), daemon=True) + cmd_thread.start() + + try: + buffer = "" + while not stop_event.is_set(): + try: + data = s.recv(1024) + except (OSError, socket.timeout): + continue + if not data: + print("Connection closed by ESP32") + break + + buffer += data.decode(errors="replace") + while "\n" in buffer: + line, buffer = buffer.split("\n", 1) + line = line.strip() + if not line: + continue + if not line.startswith("{"): + # Status or debug line + print("ESP32:", line) + continue + try: + msg = json.loads(line) + print("JSON:", msg) + except json.JSONDecodeError: + print("Bad JSON:", line) + finally: + stop_event.set() + try: + s.sendall(b"STOP\n") + except OSError: + pass + print("Sent STOP; exiting.") + + +if __name__ == "__main__": + main() diff --git a/scripts/run.sh b/scripts/run.sh index 7b556f58..05ad8654 100755 --- a/scripts/run.sh +++ b/scripts/run.sh @@ -78,6 +78,9 @@ fi # Build command with flags CMD="$BIN" +if [[ "$NOESP" == true ]]; then + CMD="$CMD --noesp" +fi if [[ "$NOGUI" == true ]]; then CMD="$CMD --nogui" fi diff --git a/tools/pserver/pserver.py b/tools/pserver/pserver.py index 3bf80d83..6eaafe77 100644 --- a/tools/pserver/pserver.py +++ b/tools/pserver/pserver.py @@ -1,6 +1,7 @@ #!/usr/bin/env python3 import socket import sys +import threading from putils import * from pstreamer import PStreamer from pstream_json import PStreamJSON @@ -8,10 +9,42 @@ class PServer: def __init__(self): self.verbose = 0 + self._streaming = False + + def _read_commands(self, client_socket, stop_event): + """Note: This runs in a separate thread""" + buffer = "" + try: + while not stop_event.is_set(): + try: + data = client_socket.recv(1024) + except OSError: + break + if not data: + break + buffer += data.decode(errors="replace") + while "\n" in buffer: + line, buffer = buffer.split("\n", 1) + line = line.strip().upper() + if not line: + continue + if line.startswith("START"): + pinfo("PServer", "Received START command") + self._streaming = True + elif line.startswith("STOP"): + pinfo("PServer", "Received STOP command") + self._streaming = False + else: + pinfo("PServer", f"Unknown command: {line}") + except Exception as e: + pinfo("PServer", f"Command reader error: {e}") + finally: + stop_event.set() def send_stream(self, client_socket, client_address, streamer: PStreamer): """ Send data from the streamer to the connected client. + Waits for a START command before sending data. Args: client_socket: Socket connected to the client @@ -19,17 +52,29 @@ def send_stream(self, client_socket, client_address, streamer: PStreamer): streamer: PStreamer instance providing data """ print(f"[PServer] Client connected from {client_address}") + pinfo("PServer", "Waiting for START command...") + + stop_event = threading.Event() + cmd_thread = threading.Thread( + target=self._read_commands, + args=(client_socket, stop_event), + daemon=True + ) + cmd_thread.start() try: counter = 0 - while True: + while not stop_event.is_set(): + if not self._streaming: + stop_event.wait(timeout=0.1) + continue + data = streamer.get_data(timeout=2.0) if data is None: pwarning("PServer", "No data available from streamer") continue - + client_socket.sendall(data) - #pinfo("PServer", f"Sent: {data.decode('utf-8').strip()}") counter += 1 except (BrokenPipeError, ConnectionResetError) as e: @@ -37,6 +82,7 @@ def send_stream(self, client_socket, client_address, streamer: PStreamer): except KeyboardInterrupt: pinfo("PServer", "Connection interrupted") finally: + stop_event.set() client_socket.close() def main(): diff --git a/tools/pserver/pstream_json.py b/tools/pserver/pstream_json.py index 739fa897..774dca13 100644 --- a/tools/pserver/pstream_json.py +++ b/tools/pserver/pstream_json.py @@ -15,7 +15,7 @@ def __init__(self): "timestamp": 123032032, "dataunit": "K", "datatype": "temperature", - "sensor:": "TM1000", + "sensor": "TM1000", "sensorID": 1 }, { @@ -23,7 +23,7 @@ def __init__(self): "timestamp": 123032032, "dataunit": "watercontent", "datatype": "humidity", - "sensor:": "HD1000", + "sensor": "HD1000", "sensorID": 2 }, { @@ -31,7 +31,7 @@ def __init__(self): "timestamp": 123032032, "dataunit": "hPa", "datatype": "pressure", - "sensor:": "PP1000", + "sensor": "PP1000", "sensorID": 3 }, { @@ -39,7 +39,7 @@ def __init__(self): "timestamp": 123032032, "dataunit": "nm", "datatype": "light", - "sensor:": "NM1000", + "sensor": "NM1000", "sensorID": 4 } ]