Compare commits

...
5 Commits
Author SHA1 Message Date
JosephFerano 614aef96c5 📟 pmme-device: backporting - Match uart_data reading from Rust 2026-05-17 07:37:52 +07:00
JosephFerano a638e0293c 📟 pmme-device: backporting - add wifi provision code 2026-05-17 07:31:26 +07:00
JosephFerano 49c123809a 📟 pmme-device: backporting - GPIO button 2026-05-16 12:57:31 +07:00
JosephFerano f850e86b2f 🌐️ pmme-ui: Refactor admin/sensors split into single core module
- Merged admin.cljs and sensors.cljs into core.cljs to consolidate UI logic
- Added WiFi network management interface with active/inactive status indicators
- Improved admin panel with async status checking and visual feedback
- Updated shadow-cljs config to use port 3000 and simplified mobile build
- Enhanced CSS with utility layer for badge-error class
2025-07-12 13:31:43 +07:00
JosephFerano 3143f3b37f 🌐️ pmme-ui: New replicant design with sensors and admin panel tabs 2025-07-12 13:31:37 +07:00
15 changed files with 2479 additions and 610 deletions

No files matched your search

+1
View File
@@ -0,0 +1 @@
/*Claude-PMME*
+111 -6
View File
@@ -12,8 +12,15 @@
#include "driver/i2c_master.h" #include "driver/i2c_master.h"
#include "driver/uart.h" #include "driver/uart.h"
#include "driver/gpio.h"
#include "ssd1306/ssd1306.h" #include "ssd1306/ssd1306.h"
#include "esp_wifi.h"
#include "esp_http_server.h"
#include "esp_netif.h"
#include "nvs_flash.h"
#include "esp_event.h"
static const char *TAG = "PMME-Device"; static const char *TAG = "PMME-Device";
#define I2C_HOST 0 #define I2C_HOST 0
@@ -29,6 +36,8 @@ static const char *TAG = "PMME-Device";
#define UART_BUF_SIZE 128 #define UART_BUF_SIZE 128
#define TXD_PIN 17 #define TXD_PIN 17
#define RXD_PIN 16 #define RXD_PIN 16
#define BUTTON_PIN GPIO_NUM_5
#define LED_PIN GPIO_NUM_19
#define SWAP_BYTES(x) ((x >> 8) | (x << 8)) #define SWAP_BYTES(x) ((x >> 8) | (x << 8))
@@ -69,7 +78,7 @@ void send_pms7003_command(uint8_t cmd, uint8_t datah, uint8_t datal) {
void read_pm_data(uint8_t *buf) { void read_pm_data(uint8_t *buf) {
uint16_t checksum = 0; uint16_t checksum = 0;
for (int i = 0; i <= 29; i++) { for (int i = 0; i <= 28; i++) {
checksum += buf[i]; checksum += buf[i];
} }
uint16_t c = (((uint16_t)buf[30]) << 8) | ((uint16_t)buf[31]); uint16_t c = (((uint16_t)buf[30]) << 8) | ((uint16_t)buf[31]);
@@ -121,13 +130,12 @@ void pm_sensor_task(void *pvParameters) {
int len = uart_read_bytes(UART_NUM, uart_data, BUF_SIZE, 100 / portTICK_PERIOD_MS); int len = uart_read_bytes(UART_NUM, uart_data, BUF_SIZE, 100 / portTICK_PERIOD_MS);
// ESP_LOGI(TAG, "Received %d bytes from sensor", len); // ESP_LOGI(TAG, "Received %d bytes from sensor", len);
if (len > 0) { if (len > 0) {
if (len == 32 && (uart_data[0] != 0x42 || uart_data[1] != 0x4D)) { if (uart_data[0] != 0x42 || uart_data[1] != 0x4D) {
ESP_LOGW(TAG, "Frame Start does not match 0x42, 0x4D, instead got %x %x", uart_data[0], uart_data[1]); ESP_LOGW(TAG, "Frame Start does not match 0x42, 0x4D, instead got %x %x", uart_data[0], uart_data[1]);
} else { } else if (len == 32) {
read_pm_data(uart_data); read_pm_data(uart_data);
// for(int i = 0; i < len && i < BUF_SIZE; i++) { } else {
// ESP_LOGI(TAG, "0x%02X", uart_data[i]); ESP_LOGW(TAG, "Unexpected frame length: %d", len);
// }
} }
} }
uart_flush(UART_NUM); uart_flush(UART_NUM);
@@ -202,6 +210,10 @@ void oled_display_task_manual(void *pvParameters) {
void oled_display_task(void *pvParameters) { void oled_display_task(void *pvParameters) {
init_ssd1306(); init_ssd1306();
char pm25_str[128]; char pm25_str[128];
ssd1306_fill_screen();
vTaskDelay(pdMS_TO_TICKS(3000));
while (1) { while (1) {
uint16_t pm25_value = 0; uint16_t pm25_value = 0;
if (xSemaphoreTake(sensor_data_mutex, pdMS_TO_TICKS(10))) { if (xSemaphoreTake(sensor_data_mutex, pdMS_TO_TICKS(10))) {
@@ -219,6 +231,97 @@ void oled_display_task(void *pvParameters) {
} }
} }
void button_led_task(void *pvParameters) {
gpio_config_t io_conf = {
.pin_bit_mask = (1ULL << LED_PIN),
.mode = GPIO_MODE_OUTPUT,
.pull_up_en = GPIO_PULLUP_DISABLE,
.pull_down_en = GPIO_PULLDOWN_DISABLE,
.intr_type = GPIO_INTR_DISABLE,
};
gpio_config(&io_conf);
io_conf.pin_bit_mask = (1ULL << BUTTON_PIN);
io_conf.mode = GPIO_MODE_INPUT;
io_conf.pull_up_en = GPIO_PULLUP_ENABLE;
gpio_config(&io_conf);
gpio_set_level(LED_PIN, 0);
int curr_state = 1; // HIGH
bool is_pressed = false;
while (1) {
int level = gpio_get_level(BUTTON_PIN);
is_pressed = false;
if (curr_state == 1 && level == 0) {
gpio_set_level(LED_PIN, 1);
curr_state = 0;
is_pressed = true;
} else if (curr_state == 0 && level == 1) {
gpio_set_level(LED_PIN, 0);
curr_state = 1;
is_pressed = true;
}
vTaskDelay(pdMS_TO_TICKS(is_pressed ? 100 : 10));
}
}
static esp_err_t provision_handler(httpd_req_t *req) {
const char *response = "<html><body>Hello world!</body></html>";
httpd_resp_send(req, response, strlen(response));
return ESP_OK;
}
void wifi_task(void *pvParameters) {
// Initialize NVS
esp_err_t ret = nvs_flash_init();
if (ret == ESP_ERR_NVS_NO_FREE_PAGES || ret == ESP_ERR_NVS_NEW_VERSION_FOUND) {
ESP_ERROR_CHECK(nvs_flash_erase());
ESP_ERROR_CHECK(nvs_flash_init());
}
ESP_ERROR_CHECK(esp_netif_init());
ESP_ERROR_CHECK(esp_event_loop_create_default());
esp_netif_create_default_wifi_ap();
wifi_init_config_t cfg = WIFI_INIT_CONFIG_DEFAULT();
ESP_ERROR_CHECK(esp_wifi_init(&cfg));
wifi_config_t wifi_config = {
.ap = {
.ssid = "PMME-Wifi",
.password = "88888888",
.ssid_len = strlen("PMME-Wifi"),
.channel = 1,
.authmode = WIFI_AUTH_WPA2_PSK,
.max_connection = 2,
},
};
ESP_ERROR_CHECK(esp_wifi_set_mode(WIFI_MODE_AP));
ESP_ERROR_CHECK(esp_wifi_set_config(WIFI_IF_AP, &wifi_config));
ESP_ERROR_CHECK(esp_wifi_start());
ESP_LOGI(TAG, "WiFi AP started: SSID=PMME-Wifi");
httpd_handle_t server = NULL;
httpd_config_t server_config = HTTPD_DEFAULT_CONFIG();
ESP_ERROR_CHECK(httpd_start(&server, &server_config));
httpd_uri_t provision_uri = {
.uri = "/api/provision",
.method = HTTP_GET,
.handler = provision_handler,
.user_ctx = NULL,
};
ESP_ERROR_CHECK(httpd_register_uri_handler(server, &provision_uri));
ESP_LOGI(TAG, "HTTP server started on port %d", server_config.server_port);
while (1) {
vTaskDelay(pdMS_TO_TICKS(500));
}
}
void app_main(void) { void app_main(void) {
sensor_data_mutex = xSemaphoreCreateMutex(); sensor_data_mutex = xSemaphoreCreateMutex();
if (sensor_data_mutex == NULL) { if (sensor_data_mutex == NULL) {
@@ -227,4 +330,6 @@ void app_main(void) {
} }
xTaskCreate(pm_sensor_task, "pm_sensor_task", 4096, NULL, 5, NULL); xTaskCreate(pm_sensor_task, "pm_sensor_task", 4096, NULL, 5, NULL);
xTaskCreate(oled_display_task, "oled_display_task", 4096, NULL, 5, NULL); xTaskCreate(oled_display_task, "oled_display_task", 4096, NULL, 5, NULL);
xTaskCreate(button_led_task, "button_led_task", 2048, NULL, 5, NULL);
xTaskCreate(wifi_task, "wifi_task", 8192, NULL, 5, NULL);
} }
@@ -559,3 +559,19 @@ esp_err_t i2c_ssd1306_buffer_to_ram(i2c_ssd1306_handle_t *i2c_ssd1306)
return err; return err;
} }
// My Functions
esp_err_t ssd1306_fill_screen(void) {
i2c_ssd1306_buffer_fill(&i2c_ssd1306);
return i2c_ssd1306_buffer_to_ram(&i2c_ssd1306);
}
void init_ssd1306_no_splash(void)
{
i2c_new_master_bus(&i2c_master_bus_config, &i2c_master_bus);
i2c_ssd1306_init(i2c_master_bus, i2c_ssd1306_config, &i2c_ssd1306);
// i2c_ssd1306_buffer_image(&i2c_ssd1306, 32, 0, (const uint8_t *)ssd1306_logo, 64, 64, false);
// i2c_ssd1306_buffer_to_ram(&i2c_ssd1306);
vTaskDelay(1000 / portTICK_PERIOD_MS);
i2c_ssd1306_buffer_clear(&i2c_ssd1306);
}
@@ -282,3 +282,8 @@ esp_err_t i2c_ssd1306_pages_to_ram(i2c_ssd1306_handle_t *i2c_ssd1306, uint8_t in
* @return ESP_OK on success, or an error code otherwise. * @return ESP_OK on success, or an error code otherwise.
*/ */
esp_err_t i2c_ssd1306_buffer_to_ram(i2c_ssd1306_handle_t *i2c_ssd1306); esp_err_t i2c_ssd1306_buffer_to_ram(i2c_ssd1306_handle_t *i2c_ssd1306);
// My Functions;
esp_err_t ssd1306_fill_screen(void);
void init_ssd1306_no_splash(void);
File diff suppressed because it is too large. Load diff
+1
View File
@@ -0,0 +1 @@
((nil . ((joe/shell-command-silent-default . "./pmme-ui/browser-reload.sh"))))
+6
View File
@@ -18,3 +18,9 @@ pom.xml.asc
.hgignore .hgignore
.hg/ .hg/
node_modules/
.tab-id
\*Claude*
public/styles.css
+1076
View File
File diff suppressed because it is too large. Load diff
+5
View File
@@ -3,6 +3,11 @@
"version": "0.0.1", "version": "0.0.1",
"private": true, "private": true,
"devDependencies": { "devDependencies": {
"daisyui": "^5.0.43",
"shadow-cljs": "3.1.5" "shadow-cljs": "3.1.5"
},
"dependencies": {
"@tailwindcss/cli": "^4.1.10",
"tailwindcss": "^4.1.10"
} }
} }
+11 -31
View File
@@ -1,32 +1,12 @@
<!doctype html> <!DOCTYPE html>
<html lang="en"> <html>
<head> <head>
<meta charset="UTF-8" /> <title>PMME</title>
<link rel="stylesheet" href="/styles.css" /> <meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1">
<title>Tauri App</title> <link rel="stylesheet" type="text/css" href="/styles.css"
</head> </head>
<body>
<body> <script src="/js/main.js"></script>
<main class="container"> </body>
<h1>Welcome to Tauri</h1>
<div class="row">
<a href="https://tauri.app" target="_blank">
<img src="/assets/tauri.svg" class="logo tauri" alt="Tauri logo" />
</a>
<a href="https://developer.mozilla.org/en-US/docs/Web/JavaScript" target="_blank">
<img src="/assets/javascript.svg" class="logo vanilla" alt="JavaScript logo" />
</a>
</div>
<p>Click on the Tauri logo to learn more about the framework.</p>
<form class="row" id="greet-form">
<input id="greet-input" placeholder="Enter a name..." />
<button type="submit">Greet</button>
</form>
<p id="greet-msg"></p>
</main>
<script src="./js/main.js"></script>
</body>
</html> </html>
-112
View File
@@ -1,112 +0,0 @@
.logo.vanilla:hover {
filter: drop-shadow(0 0 2em #ffe21c);
}
:root {
font-family: Inter, Avenir, Helvetica, Arial, sans-serif;
font-size: 16px;
line-height: 24px;
font-weight: 400;
color: #0f0f0f;
background-color: #f6f6f6;
font-synthesis: none;
text-rendering: optimizeLegibility;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
-webkit-text-size-adjust: 100%;
}
.container {
margin: 0;
padding-top: 10vh;
display: flex;
flex-direction: column;
justify-content: center;
text-align: center;
}
.logo {
height: 6em;
padding: 1.5em;
will-change: filter;
transition: 0.75s;
}
.logo.tauri:hover {
filter: drop-shadow(0 0 2em #24c8db);
}
.row {
display: flex;
justify-content: center;
}
a {
font-weight: 500;
color: #646cff;
text-decoration: inherit;
}
a:hover {
color: #535bf2;
}
h1 {
text-align: center;
}
input,
button {
border-radius: 8px;
border: 1px solid transparent;
padding: 0.6em 1.2em;
font-size: 1em;
font-weight: 500;
font-family: inherit;
color: #0f0f0f;
background-color: #ffffff;
transition: border-color 0.25s;
box-shadow: 0 2px 2px rgba(0, 0, 0, 0.2);
}
button {
cursor: pointer;
}
button:hover {
border-color: #396cd8;
}
button:active {
border-color: #396cd8;
background-color: #e8e8e8;
}
input,
button {
outline: none;
}
#greet-input {
margin-right: 5px;
}
@media (prefers-color-scheme: dark) {
:root {
color: #f6f6f6;
background-color: #2f2f2f;
}
a:hover {
color: #24c8db;
}
input,
button {
color: #ffffff;
background-color: #0f0f0f98;
}
button:active {
background-color: #0f0f0f69;
}
}
+11 -11
View File
@@ -1,26 +1,26 @@
;; shadow-cljs configuration
{:source-paths {:source-paths
["src/main" ["src/main"
"src/test"] "src/test"]
:dependencies :dependencies
[] [[no.cjohansen/replicant "2025.03.27"]]
:dev-http {3000 "public"} :dev-http {3000 "public"}
:builds :builds
{:app {:target :browser {:dev {:target :browser
:output-dir "public/js" :output-dir "public/js"
:asset-path "/js" :asset-path "/js"
:modules {:main {:init-fn pmme-ui.core/init}} :modules {:main {:init-fn pmme-ui.shell/main}}}
:devtools {:devtools-url "http://192.168.110.133:9630"}}
:mobile {:target :browser
:output-dir "public/js"
:asset-path "/js"
:modules {:main {:init-fn pmme-ui.shell/main}}
:devtools {:devtools-url "http://192.168.110.133:9630"}}
:release {:target :browser :release {:target :browser
:output-dir "../pmme-mobile/src/js" :output-dir "../pmme-mobile/src/js"
:asset-path "/js" :asset-path "/js"
:modules {:main {:init-fn pmme-ui.core/init}} :modules {:main {:init-fn pmme-ui.shell/main}}
:compiler-options {:optimizations :advanced} :compiler-options {:optimizations :advanced}}}}
:build-hooks [(shadow.resource/copy-resources
{:from "public"
:to "../pmme-mobile/src"
:exclude ["**/*.js"]})]}}}
+7
View File
@@ -0,0 +1,7 @@
@import "tailwindcss";
@source "./**/*.{clj,cljs,cljc}";
@plugin "daisyui";
@layer utilities {
.badge-error { /* This forces Tailwind to include it */ }
}
+104 -30
View File
@@ -1,36 +1,110 @@
(ns pmme-ui.core (ns pmme-ui.core
(:require [clojure.string :as str])) (:require [replicant.dom :as r]))
(defn handle-greet [event] (defn render-pm25-display [state]
(.preventDefault event) [:div.min-h-screen.bg-gradient-to-br.from-slate-50.to-slate-100.flex.items-center.justify-center.p-8
(let [input (.getElementById js/document "greet-input") [:div.card.bg-white.shadow-2xl.border-0.max-w-2xl.w-full
msg-element (.getElementById js/document "greet-msg") [:div.card-body.text-center.p-12
name (.-value input)] [:div.mb-6
(if (empty? (str/trim name)) [:h1.text-2xl.font-light.text-slate-600.mb-2 "PME"]
(set! (.-innerHTML msg-element) "Please enter a name!") [:div.text-sm.text-slate-400.uppercase.tracking-wide.font-medium "PM 2.5 Level"]]
(do
;; You could call a Tauri command here instead
(js/console.log "Testing the chrome thingie")
(set! (.-innerHTML msg-element)
(str "Hello, " name "! 👋 (from ClojureScript)!"))
(set! (.-value input) "")))))
(defn greet-handler [event] [:div.text-center.mb-8
(handle-greet event)) [:div.relative.inline-block
[:span.text-8xl.md:text-9xl.font-black.text-slate-800.leading-none.tracking-tight
(:pm25 state "250")]
[:span.absolute.top-0.-right-20.text-2xl.text-slate-400.font-light "μg/m³"]]]
(defn setup-greet-form [] [:div.mt-4
(when-let [form (.getElementById js/document "greet-form")] (let [pm25-val (js/parseInt (:pm25 state "0"))
(.addEventListener form "submit" greet-handler))) status (cond
(<= pm25-val 12) {:text "Good" :color "text-success" :bg "bg-success/10"}
(<= pm25-val 35) {:text "Moderate" :color "text-warning" :bg "bg-warning/10"}
:else {:text "Unhealthy" :color "text-error" :bg "bg-error/10"})]
[:div.badge.badge-lg.px-6.py-3.text-base.font-semibold
{:class [(:bg status) (:color status)]}
(:text status)])]
]]])
(defn add-dynamic-styling [] (defn render-wifi-table [wifi-networks]
(let [style (.createElement js/document "style")] [:div.min-h-screen.bg-gradient-to-br.from-slate-50.to-slate-100.flex.items-center.justify-center.p-8
(set! (.-textContent style) [:div.card.bg-white.shadow-2xl.border-0.max-w-2xl.w-full
"#greet-msg { color: #646cff; font-weight: bold; margin-top: 1rem; }") [:div.card-body.p-8
(.appendChild (.-head js/document) style))) [:h2.text-2xl.font-bold.text-slate-800.mb-6.text-center "Saved WiFi Networks"]
[:div.overflow-x-auto
[:table.table.w-full
[:thead
[:tr
[:th.w-full "SSID"]
[:th "Actions"]]]
[:tbody
(for [wifi wifi-networks]
[:tr {:key (:ssid wifi)}
[:td.font-bold (:ssid wifi)]
[:td
[:div.flex.gap-2
(if (:active wifi)
[:button.btn.btn-success.btn-sm.w-32.text-black.font-normal "Active"]
[:button.btn.btn-info.btn-sm.w-32.text-black.font-normal "Make Active"])
[:button.btn.btn-error.btn-sm.flex-1.font-normal "Delete"]]]])]]]
[:btn.btn.btn-accent.mt-2 {:on {:click ::add-wifi}} "+ Add Wifi"]]]])
(defn init [] (defn render-admin-panel [state]
(js/console.log "PMME UI initialized!") (render-wifi-table [{:ssid "The Brick" :active true} {:ssid "Home" :active false}])
(setup-greet-form) )
(add-dynamic-styling)
;; Remove the app element modification since it doesn't exist in this HTML ;; [:div.min-h-screen.bg-gradient-to-br.from-slate-50.to-slate-100.flex.items-center.justify-center.p-8
(js/console.log "Greet form is now powered by ClojureScript!")) ;; [:div.card.bg-white.shadow-2xl.border-0.max-w-2xl.w-full
;; [:div.card-body.p-8
;; [:h2.text-2xl.font-bold.text-slate-800.mb-6.text-center "ESP32 Admin Panel"]
;; [:div.space-y-4
;; [:div.form-control
;; [:label.label.w-32.items-center [:span.label-text "WiFi SSID"]]
;; [:input.input.input-bordered {:type "text" :placeholder "Enter WiFi network name"}]]
;; [:div.form-control
;; [:label.label.w-32 [:span.label-text "WiFi Password"]]
;; [:input.input.input-bordered {:type "password" :placeholder "Enter WiFi password"}]]
;; [:div.form-control
;; [:label.label.w-32 [:span.label-text "Device Name"]]
;; [:input.input.input-bordered {:type "text" :placeholder "Enter device name"}]]
;; [:div.flex.gap-2.mt-6
;; [:button.btn.btn-primary.flex-1 "Provision Device"]
;; [:button.btn.btn-outline.flex-1 "Reset Device"]]]]]]
(defn render-current-content [state]
(case (:current-tab state)
:admin (render-admin-panel state)
:readings (render-pm25-display state)))
(defn render-tabs [state]
(when (:show-tabs state)
[:div.fixed.top-4.w-full.flex.justify-center
[:div.tabs.tabs-boxed.bg-white-90.backdrop-blur-sm.shadow-lg.border.border-slate-200
[:button.tab
{:class (when (= (:current-tab state) :readings) "tab-active")
:on {:click [::switch-tab :readings]}}
"Current Readings"]
[:button.tab
{:class (when (= (:current-tab state) :admin) "tab-active")
:on {:click [::switch-tab :admin]}}
"Admin Panel"
(case (:admin-status state)
::available
[:div.ml-2 {:class ["inline-grid" "*:[grid-area:1/1]"]}
[:div.status.status-success.animate-ping]
[:div.status.status-success]]
::unavailable
[:div.status.status-error.ml-2]
::unknown
[:div.status.status-neutral.ml-2])]]]))
(defn render-ui [state]
(r/render
js/document.body
[:div
(render-tabs state)
(render-current-content state)]))
+46
View File
@@ -0,0 +1,46 @@
(ns pmme-ui.shell
(:require [replicant.dom :as r]
[pmme-ui.core :as core]
[cljs.core.async :as async :refer [go <!]]
[cljs.core.async.interop :refer [<p!]]))
(defonce store (atom {:number 0
:current-tab :readings
:show-tabs true
:admin-status ::core/unknown}))
(defn ping-admin-endpoint []
(go
(try
(<p! (js/fetch "http://localhost:8080"))
true
(catch js/Error e
false))))
(defn init [store]
(add-watch store ::render (fn [_ _ _ new-state] (core/render-ui new-state)))
(r/set-dispatch!
(fn [_ event-data]
(let [[action & args] event-data]
(case action
::core/switch-tab
(let [target-tab (first args)]
(if (= target-tab :admin)
(go
;; (let [admin-available? (<! (ping-admin-endpoint))]
(let [admin-available? true]
(if admin-available?
(do
(swap! store assoc :current-tab :admin)
(swap! store assoc :admin-status ::core/available))
(swap! store assoc :admin-status ::core/unavailable))))
(swap! store assoc :current-tab target-tab)))))))
(swap! store assoc ::loaded-at (.getTime (js/Date.))))
(defn main []
(init store)
(println "Loaded!"))
(defn ^:dev/after-load reload []
(init store)
(println "Reloaded"))