Skip to content
Merged

Dev #19

Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 26 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,32 @@ The script will automatically:

**Installation time:** 1-3 minutes (no compilation required!)

### First-Time Setup Wizard

On your first visit to PyUnit, you'll be greeted by a **3-step setup wizard** that guides you through initial configuration:

#### Step 1: Configure Moonraker Connection
- Enter your Moonraker IP address
- **For Docker users:** Use your machine's network IP (e.g., `192.168.1.100`)
- **For local installations:** You can use `127.0.0.1`
- Default port: `7125`
- Test connection before saving

#### Step 2: Add Your First Dryer
- Configure all hardware components:
- Heater (must be configured in Klipper)
- Fan (PWM control)
- Temperature and humidity sensor
- LED indicators
- Servo door control
- All components must be pre-configured in your Klipper configuration

#### Step 3: Start Using PyUnit
- Once configured, access the dashboard
- Monitor and control your dryer units

**Note:** The wizard can be skipped using "Skip setup for now" if you want to configure manually later.

### Requirements

- **OS:** Linux (Ubuntu, Debian, Raspbian, Armbian)
Expand Down
4 changes: 2 additions & 2 deletions api/database.py
Original file line number Diff line number Diff line change
Expand Up @@ -145,8 +145,8 @@ async def seed_db():

has_moonraker = (await session.execute(MoonrakerConfig.__table__.select().limit(1))).first() is not None
if not has_moonraker:
db_logger.info("Seeding default Moonraker config (127.0.0.1)")
session.add(MoonrakerConfig(moonraker_ip="127.0.0.1"))
db_logger.info("Seeding default Moonraker config (127.0.0.2)")
session.add(MoonrakerConfig(moonraker_ip="127.0.0.2"))

await session.commit()
except Exception:
Expand Down
50 changes: 38 additions & 12 deletions api/endpoints/config_page.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,8 @@
from sqlalchemy.ext.asyncio import AsyncSession
from api.tools.moonraker_api import Moonraker_api
from typing import Any
import aiohttp
import asyncio

router = APIRouter()
logger = get_logger("config_page_endpoint")
Expand Down Expand Up @@ -60,18 +62,42 @@ async def update_moonraker_config(config: moonrkaerConfigSchema.MoonrakerConfigU


@router.post("/moonraker/test-connection")
async def test_connection_to_moonraker(db: AsyncSession = Depends(get_db)):
"""Test connectivity to Moonraker server using stored configuration."""
logger.debug("POST /config/moonraker/test-connection")
moonraker_api = Moonraker_api(db)
await moonraker_api.initialize()
result: dict[str, Any] = await moonraker_api.get_info()
success = bool(result.get('success'))
if success:
logger.info("Moonraker connection successful")
return {"success": True, "message": "Connection successful"}
logger.warning("Moonraker connection failed message=%s", result.get('message'))
return result
async def test_connection_to_moonraker(config: moonrkaerConfigSchema.MoonrakerConfigBase, db: AsyncSession = Depends(get_db)):
"""Test connectivity to Moonraker server using provided configuration.

Args:
config: Moonraker connection parameters to test
db: Database session (unused, kept for consistency)

Returns:
dict with success status and message
"""
logger.debug("POST /config/moonraker/test-connection ip=%s port=%s", config.moonraker_ip, config.moonraker_port)

# Build URL from provided config
url = f"{config.moonraker_api_method}://{config.moonraker_ip}:{config.moonraker_port}/printer/info"
headers = {}
if config.moonraker_api_key:
headers["X-Api-Key"] = config.moonraker_api_key

# Test connection directly without saving to DB
try:
async with aiohttp.ClientSession() as session:
async with session.get(url, headers=headers, timeout=10) as response:
if response.status == 200:
logger.info("Moonraker connection successful ip=%s", config.moonraker_ip)
return {"success": True, "message": "Connection successful"}
logger.warning("Moonraker connection failed ip=%s status=%s", config.moonraker_ip, response.status)
return {"success": False, "message": f"Moonraker returned status {response.status}"}
except aiohttp.ClientConnectorError as e:
logger.warning("Moonraker connection error ip=%s error=%s", config.moonraker_ip, str(e))
return {"success": False, "message": f"Connection error: {str(e)}"}
except asyncio.TimeoutError:
logger.warning("Moonraker connection timeout ip=%s", config.moonraker_ip)
return {"success": False, "message": "Connection timeout"}
except Exception as e:
logger.error("Moonraker connection exception ip=%s error=%s", config.moonraker_ip, str(e))
return {"success": False, "message": f"Error: {str(e)}"}


@router.get("/moonraker/get-objects-list")
Expand Down
2 changes: 1 addition & 1 deletion api/schemas/moonraker_config_schema.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ def __str__(self): # pragma: no cover - trivial
class MoonrakerConfigBase(BaseModel):
"""Base parameters required to talk to a Moonraker instance."""
moonraker_api_method: MoonrakerMethod = Field(default=MoonrakerMethod.HTTP, description="Protocol scheme")
moonraker_ip: str = Field(default="127.0.0.1", max_length=50, description="Moonraker host/IP")
moonraker_ip: str = Field(default="127.0.0.2", max_length=50, description="Moonraker host/IP")
moonraker_port: int = Field(default=7125, ge=1, le=65535, description="Moonraker port")
moonraker_api_key: Optional[str] = Field(None, max_length=100, description="Optional API key for auth")

Expand Down
2 changes: 1 addition & 1 deletion build.sh
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ echo ""
# Configuration - CHANGE THIS!
DOCKER_USERNAME="xatang" # Your Docker Hub username
DOCKER_IMAGE="${DOCKER_USERNAME}/pyunit"
VERSION="latest" # or use version like "2.0.1"
VERSION="latest" # or use version like "2.0.2"

echo "Docker Image: ${DOCKER_IMAGE}:${VERSION}"
echo ""
Expand Down
2 changes: 1 addition & 1 deletion main.py
Original file line number Diff line number Diff line change
Expand Up @@ -46,7 +46,7 @@ async def lifespan(app: FastAPI):
app = FastAPI(
title="PyUnit",
description="Api for PyUnit",
version="2.0.1",
version="2.0.2",
debug=True,
lifespan=lifespan
)
Expand Down
2 changes: 1 addition & 1 deletion web/.angular/cache/20.3.1/pyunit/.tsbuildinfo

Large diffs are not rendered by default.

2 changes: 1 addition & 1 deletion web/dist/pyunit/browser/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -11,5 +11,5 @@
<link rel="stylesheet" href="styles-SBLEVL4T.css"></head>
<body>
<app-root></app-root>
<link rel="modulepreload" href="chunk-FDERIQAA.js"><script src="polyfills-5CFQRCPP.js" type="module"></script><script src="scripts-7SWBP3MU.js" defer></script><script src="main-3TMCODMY.js" type="module"></script></body>
<link rel="modulepreload" href="chunk-FDERIQAA.js"><script src="polyfills-5CFQRCPP.js" type="module"></script><script src="scripts-7SWBP3MU.js" defer></script><script src="main-56R5IKVF.js" type="module"></script></body>
</html>
2 changes: 1 addition & 1 deletion web/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "pyunit",
"version": "2.0.1",
"version": "2.0.2",
"scripts": {
"ng": "ng",
"start": "ng serve",
Expand Down
19 changes: 18 additions & 1 deletion web/src/app/app.routes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,10 +7,18 @@ import { LayoutComponent } from './common-ui/layout/layout';
import { ConfigsPage } from './pages/configs/configs';
import { DryerChartEmbed } from './pages/embed/dryer-chart-embed/dryer-chart-embed';
import { DryerControlEmbed } from './pages/embed/dryer-control-embed/dryer-control-embed';
import { WelcomePage } from './pages/welcome/welcome';
import { setupGuard } from './guards/setup.guard';
import { MoonrakerConfigPage } from './pages/configs/moonraker-config/moonraker-config';
import { DryerConfigPage } from './pages/configs/dryer-config/dryer-config';

export const routes: Routes = [
// Welcome page (no layout, no guard)
{ path: 'welcome', component: WelcomePage },

// Main application routes with layout and setup guard
{
path: "", component: LayoutComponent, children: [
path: "", component: LayoutComponent, canActivate: [setupGuard], children: [
{ path: '', pathMatch: 'full', redirectTo: 'dashboard' },
{ path: 'dashboard', component: DashboardPage },
{ path: 'dashboard/:dryerId', component: DashboardDryer },
Expand All @@ -19,6 +27,15 @@ export const routes: Routes = [
{ path: 'config', component: ConfigsPage },
]
},

// Config pages with layout but WITHOUT guard (needed for setup)
{
path: "configs", component: LayoutComponent, children: [
{ path: 'moonraker', component: MoonrakerConfigPage },
{ path: 'dryer', component: DryerConfigPage },
]
},

// Embedded pages for Klipper HTTP camera (4:3 aspect ratio, no layout)
{ path: 'embed/dryer/:dryerId/chart', component: DryerChartEmbed },
{ path: 'embed/dryer/:dryerId/control', component: DryerControlEmbed }
Expand Down
66 changes: 66 additions & 0 deletions web/src/app/guards/setup.guard.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
import { inject } from '@angular/core';
import { Router, CanActivateFn } from '@angular/router';
import { map, catchError, of, switchMap } from 'rxjs';
import { HttpClient } from '@angular/common/http';
import { environment } from '../../environments';

/**
* Guard that checks if initial setup is complete
* Redirects to welcome page if no moonraker config or dryers exist
* Can be bypassed with skipSetup flag in sessionStorage
*/
export const setupGuard: CanActivateFn = (route, state) => {
const http = inject(HttpClient);
const router = inject(Router);
const apiUrl = environment.apiUrl;

// Check if user has skipped setup
const skipSetup = sessionStorage.getItem('skipSetup') === 'true';
if (skipSetup) {
console.log('Setup guard bypassed by skipSetup flag');
return true;
}

// First check if moonraker is configured properly
return http.get<any>(`${apiUrl}/config/moonraker`).pipe(
switchMap(config => {
// Check if IP is not default invalid value (127.0.0.2)
const moonrakerConfigured = config &&
config.moonraker_ip &&
config.moonraker_ip !== '' &&
config.moonraker_ip !== '127.0.0.2';

if (!moonrakerConfigured) {
// Moonraker not configured, redirect to welcome
router.navigate(['/welcome']);
return of(false);
}

// Moonraker is configured, check if dryers exist
return http.get<any[]>(`${apiUrl}/common/units`).pipe(
map(dryers => {
if (dryers && dryers.length > 0) {
// Setup is complete
return true;
} else {
// No dryers found, redirect to welcome page
router.navigate(['/welcome']);
return false;
}
}),
catchError(error => {
// On error, redirect to welcome page
console.error('Dryers check failed:', error);
router.navigate(['/welcome']);
return of(false);
})
);
}),
catchError(error => {
// On error, redirect to welcome page
console.error('Setup check failed:', error);
router.navigate(['/welcome']);
return of(false);
})
);
};
36 changes: 21 additions & 15 deletions web/src/app/pages/configs/dryer-config/dryer-config.html
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ <h5 class="card-title mb-0">
</h5>
</div>
<div class="card-body position-relative" style="min-height: 500px;">
<!-- Индикатор загрузки -->
<!-- Loading indicator -->
<div class="loading-overlay"
[class.loading-visible]="isLoading"
[class.loading-hidden]="!isLoading">
Expand All @@ -17,12 +17,12 @@ <h5 class="card-title mb-0">
</div>
</div>

<!-- Основной контент -->
<!-- Main content -->
<div class="content-container"
[class.content-visible]="!isLoading"
[class.content-hidden]="isLoading">

<!-- Список сушилок -->
<!-- List of dryers -->
<div class="mb-4" *ngIf="dryers.length > 0">
<h6 class="mb-3">Existing Dryer Units</h6>
<div class="list-group">
Expand All @@ -37,11 +37,11 @@ <h6 class="mb-3">Existing Dryer Units</h6>
</div>
</div>

<!-- Форма добавления/редактирования -->
<!-- Add/Edit form -->
<h6 class="mb-3">{{ editingDryer ? 'Edit Dryer Unit' : 'Add New Dryer Unit' }}</h6>
<form id="dryerForm" [formGroup]="dryerForm" (ngSubmit)="onSubmit()">

<!-- Название сушилки -->
<!-- Dryer name -->
<div class="mb-3">
<label for="dryerName" class="form-label required-field">Dryer Name</label>
<input type="text" class="form-control" id="dryerName"
Expand All @@ -53,7 +53,7 @@ <h6 class="mb-3">{{ editingDryer ? 'Edit Dryer Unit' : 'Add New Dryer Unit' }}</
</div>
</div>

<!-- Конфигурация нагревателя -->
<!-- Heater configuration -->
<div class="card mb-3" formGroupName="config">
<div class="card-header bg-primary">
<h6 class="mb-0">Heater Configuration</h6>
Expand Down Expand Up @@ -94,7 +94,7 @@ <h6 class="mb-0">Heater Configuration</h6>
</div>
</div>

<!-- Конфигурация LED -->
<!-- LED configuration -->
<div class="card mb-3" formGroupName="config">
<div class="card-header bg-primary">
<h6 class="mb-0">LED Configuration</h6>
Expand Down Expand Up @@ -132,7 +132,7 @@ <h6 class="mb-0">LED Configuration</h6>
</div>
</div>

<!-- Конфигурация влажности -->
<!-- Humidity configuration -->
<div class="card mb-3" formGroupName="config">
<div class="card-header bg-primary">
<h6 class="mb-0">Humidity Configuration</h6>
Expand Down Expand Up @@ -208,31 +208,31 @@ <h6 class="mb-0">Humidity Configuration</h6>
</div>
</div>

<!-- Конфигурация температуры -->
<!-- Temperature configuration -->
<div class="card mb-3" formGroupName="config">
<div class="card-header bg-primary">
<h6 class="mb-0">Temperature Configuration</h6>
</div>
<div class="card-body" formGroupName="temperature">
<div class="mb-3">
<label for="tempSensor" class="form-label required-field">Temperature Sensor Name</label>
<label for="tempSensor" class="form-label required-field">Air Temperature and Humidity Sensor Name</label>
<select class="form-select" id="tempSensor"
formControlName="sensor_name"
[class.is-invalid]="temperatureControls['sensor_name'].invalid && (temperatureControls['sensor_name'].dirty || temperatureControls['sensor_name'].touched)">
<option value="">Select Temperature Sensor</option>
<option value="">Select Air Temperature and Humidity Sensor</option>
<option *ngFor="let sensor of temperatureSensors" [value]="sensor">{{ sensor }}</option>
</select>
<div *ngIf="temperatureControls['sensor_name'].invalid && (temperatureControls['sensor_name'].dirty || temperatureControls['sensor_name'].touched)" class="invalid-feedback">
Temperature sensor name is required
Air temperature and humidity sensor name is required
</div>
<div class="form-text" *ngIf="temperatureSensors.length === 0">
No temperature sensors found. Make sure sensors are configured in Moonraker.
No air temperature and humidity sensors found. Make sure sensors are configured in Moonraker.
</div>
</div>
</div>
</div>

<!-- Конфигурация серво -->
<!-- Servo configuration -->
<div class="card mb-3" formGroupName="config">
<div class="card-header bg-primary">
<h6 class="mb-0">Servo Configuration</h6>
Expand Down Expand Up @@ -322,8 +322,14 @@ <h6 class="mb-0">Servo Configuration</h6>
</div>
</div>

<!-- Кнопки действий -->
<!-- Action buttons -->
<div class="d-grid gap-2 d-md-flex justify-content-md-end mt-4">
<button *ngIf="isFromWelcome" type="button" class="btn btn-outline-primary me-md-2"
(click)="backToWelcome()"
[disabled]="isSaving">
<i class="bi bi-arrow-left me-1"></i>
Back to Setup
</button>
<button type="button" class="btn btn-outline-secondary me-md-2"
(click)="resetForm()"
[disabled]="isSaving">
Expand Down
Loading