Adds feed valve button + reformatting

This commit is contained in:
Etienne Chassaing 2025-07-15 13:51:05 +02:00
parent e21bfa26f6
commit 1545a01350
3 changed files with 154 additions and 145 deletions

View File

@ -28,7 +28,7 @@ class CANBackend:
# Placeholder for sending mode command
PUs_states[pu_number-1] = {"PU_MODE": state, "ploop_setpoint":ploop_setpoint}
def get_latest_data(self):
def get_latest_data(self, pu_number=1, data={}):
# Simulate getting the latest data with random values
return {
"FM2": round(1000 * np.random.random(), 1),

55
main.py
View File

@ -5,17 +5,20 @@ import logging
import os
from fastapi import Request, APIRouter
import platform
from fastapi.templating import Jinja2Templates # pip install fastapi uvicorn jinja2 python-multipart passlib
from fastapi.templating import (
Jinja2Templates,
) # pip install fastapi uvicorn jinja2 python-multipart passlib
from starlette.middleware.sessions import SessionMiddleware
from starlette.exceptions import HTTPException as StarletteHTTPException
from starlette.status import HTTP_302_FOUND
import json
from pathlib import Path
from typing import Optional
from fastapi import Query
if platform.system() in ['Darwin']: # macOS or Windows
if platform.system() in ["Darwin"]: # macOS or Windows
from MockCAN import CANBackend
else :
else:
from classCAN import CANBackend # Your real backend
app = FastAPI()
@ -53,12 +56,15 @@ PASSWORD = CREDENTIALS["password"]
# ======== LOGIN & SESSION HANDLING ========
def require_login(request: Request):
user = request.session.get("user")
if user != USERNAME:
# raise 302 to trigger redirection manually (FastAPI doesn't support redirects from Depends directly)
raise StarletteHTTPException(status_code=302, detail="Redirect to login")
return user
@app.get("/", response_class=HTMLResponse)
def login_form(request: Request):
return templates.TemplateResponse("login.html", {"request": request})
@ -69,7 +75,9 @@ def login(request: Request, username: str = Form(...), password: str = Form(...)
if username == USERNAME and password == PASSWORD:
request.session["user"] = username
return RedirectResponse("/control", status_code=HTTP_302_FOUND)
return templates.TemplateResponse("login.html", {"request": request, "error": "Invalid credentials.json"})
return templates.TemplateResponse(
"login.html", {"request": request, "error": "Invalid credentials.json"}
)
@app.get("/logout")
@ -80,19 +88,23 @@ def logout(request: Request):
# ======== PROTECTED INTERFACE ========
@app.get("/control", response_class=HTMLResponse)
def control_page(request: Request):
if request.session.get("user") != USERNAME:
return RedirectResponse("/", status_code=HTTP_302_FOUND)
return templates.TemplateResponse("control.html", {"request": request})
@app.get("/monitor-page", response_class=HTMLResponse)
def monitor_page(request: Request):
with open("static/monitor.html") as f:
return HTMLResponse(f.read())
# ======== CAN + BACKEND ROUTES ========
@app.post("/connect_toggle")
def connect_toggle():
logging.info("Toggling CAN connection...")
@ -109,8 +121,13 @@ def connect_toggle():
@app.post("/command/{state}/pu/{pu_number}")
def send_command(state: str, pu_number: int, ploop_setpoint: float = Query(...)):
VALID_STATES = {
"IDLE", "PRE-PRODUCTION", "PRODUCTION", "FIRST_START",
"THERMALLOOPCLEANING", "DISINFECTION", "SLEEP"
"IDLE",
"PRE-PRODUCTION",
"PRODUCTION",
"FIRST_START",
"THERMALLOOPCLEANING",
"DISINFECTION",
"SLEEP",
}
state = state.upper()
@ -128,7 +145,7 @@ def send_command(state: str, pu_number: int, ploop_setpoint: float = Query(...))
"command": state,
"pu": pu_number,
"ploop_setpoint": ploop_setpoint,
"current_state": current_state
"current_state": current_state,
}
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))
@ -145,9 +162,6 @@ def get_pu_status():
return JSONResponse(content=states)
from typing import Optional
from fastapi import Query
@app.get("/monitor")
def get_monitor_data(pu_number: Optional[int] = Query(None)):
def format_data(data):
@ -156,30 +170,22 @@ def get_monitor_data(pu_number: Optional[int] = Query(None)):
"Qdilute": data.get("FM2", 0.0),
"Qdrain": data.get("FM3", 0.0),
"Qrecirc": data.get("FM4", 0.0),
"Pro": data.get("PS1", 0.0),
"Pdilute": data.get("PS2", 0.0),
"Prentate": data.get("PS3", 0.0),
"Conductivity": data.get("Cond", 0.0),
"MV02": data.get("MV02", 0.0),
"MV02_sp": data.get("MV02_sp", 0.0),
"MV03": data.get("MV03", 0.0),
"MV03_sp": data.get("MV03_sp", 0.0),
"MV05": data.get("MV05", 0.0),
"MV05_sp": data.get("MV05_sp", 0.0),
"MV06": data.get("MV06", 0.0),
"MV06_sp": data.get("MV06_sp", 0.0),
"MV07": data.get("MV07", 0.0),
"MV07_sp": data.get("MV07_sp", 0.0),
"MV08": data.get("MV08", 0.0),
"MV08_sp": data.get("MV08_sp", 0.0)
"MV08_sp": data.get("MV08_sp", 0.0),
}
if pu_number is not None:
@ -201,10 +207,19 @@ def can_status():
return {"connected": can_backend.connected}
@app.post("/command/feed_valve")
def can_status(MV01_opening: int = Query(...)):
"""Control MV01 feed valve"""
# can_backend.send_command(MV01_opening) # TODO: TODO
logging.info(f"Feed valve opening to {MV01_opening}")
return {"status": "ok"}
app.include_router(router)
if __name__ == "__main__":
import uvicorn
uvicorn.run(
"main:app",
host="127.0.0.1",

View File

@ -10,14 +10,13 @@
font-family: Arial, sans-serif;
margin: 0;
padding: 0;
overflow-x: hidden; /* ✅ prevent horizontal scroll */
overflow-x: hidden;
height: 100vh;
background-color: #121212;
color: white;
display: flex;
flex-direction: column;
}
.header {
background-color: #1e1e1e;
padding: 10px 20px;
@ -45,7 +44,6 @@
overflow-x: hidden;
box-sizing: border-box;
}
.left-panel, .right-panel {
flex: 1;
padding: 20px;
@ -88,17 +86,14 @@
background-color: yellow !important;
color: black !important;
}
.ready {
background-color: orange !important;
color: black !important;
}
.production {
background-color: green !important;
color: white !important;
}
.pu-status { margin-top: 20px; }
.pu-item {
background-color: #333;
@ -148,7 +143,6 @@
width: 100%;
overflow: hidden;
}
.slider-values span#currentValue {
font-weight: bold;
color: #00bfff;
@ -178,6 +172,23 @@
font-weight: bold;
}
.monitor-link:hover { background-color: #0056b3; }
.feed-valve-buttons {
display: flex;
gap: 10px;
margin-top: 10px;
}
.feed-valve-buttons button {
flex: 1;
padding: 10px;
border: none;
border-radius: 5px;
cursor: pointer;
background-color: #444;
color: white;
}
.feed-valve-buttons button.active {
background-color: #00C851;
}
</style>
</head>
<body>
@ -230,6 +241,11 @@
<div class="pu-item"><span>PU 2: </span><span id="pu2-status">Offline</span></div>
<div class="pu-item"><span>PU 3: </span><span id="pu3-status">Offline</span></div>
</div>
<div class="feed-valve-buttons">
<button onclick="setFeedValve(0, this)">Feed Valve 0%</button>
<button onclick="setFeedValve(50, this)">Feed Valve 50%</button>
<button onclick="setFeedValve(100, this)">Feed Valve 100%</button>
</div>
</div>
<div class="right-panel">
<div class="monitor-block">
@ -266,48 +282,39 @@
</div>
</div>
</div>
<script>
function updatePloopSetpoint(value) {
document.getElementById('currentValue').textContent = value;
}
async function toggleConnection() {
async function toggleConnection() {
const response = await fetch('/connect_toggle', { method: 'POST' });
const data = await response.json();
const connectButton = document.getElementById('connectButton');
connectButton.classList.toggle('connected', data.connected);
connectButton.innerHTML = `<i class="fas fa-power-off"></i> ${data.connected ? 'Disconnect' : 'Connect'}`;
}
}
async function sendCommand(state, puNumber, buttonEl) {
async function sendCommand(state, puNumber, buttonEl) {
const ploopSetpoint = document.getElementById('ploopSetpoint').value;
// Send command to backend
await fetch(`/command/${state}/pu/${puNumber}?ploop_setpoint=${ploopSetpoint}`, { method: 'POST' });
// Reset button styles
document.querySelectorAll('button').forEach(btn => {
btn.classList.remove('in-progress', 'ready', 'production');
});
// Handle PRE-PRODUCTION sequence
if (state === 'PRE-PRODUCTION') {
buttonEl.classList.add('in-progress');
buttonEl.textContent = `Waiting... PU ${puNumber}`;
buttonEl.disabled = true;
const checkReady = async () => {
const res = await fetch(`/api/pu_status`);
const states = await res.json();
const currentState = states[`PU${puNumber}`];
if (currentState === 'SYSTEM_MODE_READY') {
buttonEl.classList.remove('in-progress');
buttonEl.classList.add('ready');
buttonEl.textContent = `START PRODUCTION PU ${puNumber}`;
buttonEl.disabled = false;
buttonEl.onclick = async () => {
await sendCommand("PRODUCTION", puNumber, buttonEl);
buttonEl.classList.remove('ready');
@ -319,19 +326,15 @@ function updatePloopSetpoint(value) {
setTimeout(checkReady, 1000);
}
};
checkReady();
} else if (state === 'PRODUCTION') {
buttonEl.classList.add('production');
buttonEl.textContent = `PRODUCTION ON PU ${puNumber}`;
} else if (state === 'IDLE' || state === 'FIRST_START') {
buttonEl.classList.remove('in-progress', 'ready', 'production');
buttonEl.classList.add('production');
buttonEl.textContent = `${state.replace('_', ' ')} PU ${puNumber}`;
// === Reset PRE-PROD button ===
const preProdBtn = document.querySelector(`button[data-action="PRE-PRODUCTION"][data-pu="${puNumber}"]`);
if (preProdBtn) {
preProdBtn.classList.remove('in-progress', 'ready', 'production');
@ -340,7 +343,6 @@ function updatePloopSetpoint(value) {
preProdBtn.onclick = () => sendCommand("PRE-PRODUCTION", puNumber, preProdBtn);
}
// === Reset IDLE button ===
const idleBtn = document.querySelector(`button[data-action="IDLE"][data-pu="${puNumber}"]`);
if (idleBtn && idleBtn !== buttonEl) {
idleBtn.classList.remove('in-progress', 'ready', 'production');
@ -349,7 +351,6 @@ function updatePloopSetpoint(value) {
idleBtn.onclick = () => sendCommand("IDLE", puNumber, idleBtn);
}
// === Reset FIRST START button ===
const firstStartBtn = document.querySelector(`button[data-action="FIRST_START"][data-pu="${puNumber}"]`);
if (firstStartBtn && firstStartBtn !== buttonEl) {
firstStartBtn.classList.remove('in-progress', 'ready', 'production');
@ -360,37 +361,41 @@ function updatePloopSetpoint(value) {
}
}
async function fetchPUStatus() {
async function setFeedValve(opening, buttonEl) {
await fetch(`/command/feed_valve?MV01_opening=${opening}`, { method: 'POST' });
document.querySelectorAll('.feed-valve-buttons button').forEach(btn => {
btn.classList.remove('active');
});
buttonEl.classList.add('active');
}
async function fetchPUStatus() {
const response = await fetch("/api/pu_status");
const data = await response.json();
document.getElementById("pu1-status").textContent = data.PU1 || "Unknown";
document.getElementById("pu2-status").textContent = data.PU2 || "Unknown";
document.getElementById("pu3-status").textContent = data.PU3 || "Unknown";
}
fetchPUStatus();
setInterval(fetchPUStatus, 5000);
}
async function updateMonitorData() {
fetchPUStatus();
setInterval(fetchPUStatus, 5000);
async function updateMonitorData() {
const response = await fetch('/monitor');
const data = await response.json(); // data = { PU_1: {...}, PU_2: {...}, PU_3: {...} }
const data = await response.json();
for (const [puId, puData] of Object.entries(data)) {
const container = document.getElementById(puId);
if (!container) continue;
container.innerHTML = `
<h3>${puId}</h3>
<div class="monitor-value">Q_perm<br>${puData.Qperm.toFixed(1)} L/h</div>
<div class="monitor-value">Q_dilute<br>${puData.Qdilute.toFixed(1)} L/h</div>
<div class="monitor-value">Q_drain<br>${puData.Qdrain.toFixed(1)} L/h</div>
<div class="monitor-value">Q_recirc<br>${puData.Qrecirc.toFixed(1)} L/h</div>
<div class="monitor-value">P_ro<br>${puData.Pro.toFixed(1)} bar</div>
<div class="monitor-value">P_dilute<br>${puData.Pdilute.toFixed(1)} bar</div>
<div class="monitor-value">P_rentate<br>${puData.Prentate.toFixed(1)} bar</div>
<div class="monitor-value">Conductivity<br>${puData.Conductivity.toFixed(1)} µS/cm</div>
<div class="monitor-value">MV02<br>${puData.MV02.toFixed(1)} % (sp: ${puData.MV02_sp.toFixed(1)})</div>
<div class="monitor-value">MV03<br>${puData.MV03.toFixed(1)} % (sp: ${puData.MV03_sp.toFixed(1)})</div>
<div class="monitor-value">MV05<br>${puData.MV05.toFixed(1)} % (sp: ${puData.MV05_sp.toFixed(1)})</div>
@ -399,9 +404,9 @@ function updatePloopSetpoint(value) {
<div class="monitor-value">MV08<br>${puData.MV08.toFixed(1)} % (sp: ${puData.MV08_sp.toFixed(1)})</div>
`;
}
}
}
function updateMonitorValues(id, values, unit) {
function updateMonitorValues(id, values, unit) {
const container = document.getElementById(id);
const valueElements = container.querySelectorAll('.monitor-value');
valueElements.forEach((element, index) => {
@ -409,56 +414,45 @@ function updatePloopSetpoint(value) {
element.innerHTML = `#${index + 1}<br>${values[index]} ${unit}`;
}
});
}
}
// Update monitor data every second
setInterval(updateMonitorData, 1000);
</script>
setInterval(updateMonitorData, 1000);
<script>
async function fetchMonitorData() {
async function fetchMonitorData() {
try {
const puMap = {
"PU_1": 1,
"PU_2": 2,
"PU_3": 3
};
const fields = {
"Qperm": "L/h",
"Pdilute": "bar",
"Conductivity": "µS/cm",
"Pro": "bar"
};
const dataResponse = await fetch('/monitor');
const allData = await dataResponse.json(); // Returns {PU_1: {...}, PU_2: {...}, PU_3: {...}}
const allData = await dataResponse.json();
for (const [fieldId, unit] of Object.entries(fields)) {
const container = document.getElementById(fieldId);
if (!container) continue;
const valueElements = container.querySelectorAll('.monitor-value');
let index = 0;
for (const [puLabel, puData] of Object.entries(allData)) {
const value = puData[fieldId] ?? 0.0;
// Reuse DOM element if it exists
if (valueElements[index]) {
valueElements[index].innerHTML = `#${index + 1}<br>${value.toFixed(1)} ${unit}`;
}
index++;
}
}
} catch (error) {
console.error('Error fetching monitor data:', error);
}
}
}
setInterval(fetchMonitorData, 1000);
fetchMonitorData(); // initial load
</script>
setInterval(fetchMonitorData, 1000);
fetchMonitorData();
</script>
</body>
</html>