Skip to content
Merged
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
14 changes: 12 additions & 2 deletions pi/appliance.py
Original file line number Diff line number Diff line change
Expand Up @@ -27,11 +27,10 @@ def default_config() -> dict:
"max_prints": 3,
"theme": "green",
"printer_device": "/dev/usb/lp0",
"quiet_enabled": True,
"print_mode": "scheduled", # "on", "scheduled", or "off"
"quiet_start": "22:00",
"quiet_end": "08:00",
"quiet_wake_mode": "latest",
"enabled": True,
"auth_user": "",
"auth_hash": "",
"secret_key": "",
Expand All @@ -50,6 +49,17 @@ def load_config() -> dict:
saved = json.load(f)
# Merge saved values over defaults so new keys get defaults
merged = {**defaults, **saved}
# Migrate legacy enabled/quiet_enabled to print_mode
if "print_mode" not in saved:
if not saved.get("enabled", True):
merged["print_mode"] = "off"
elif saved.get("quiet_enabled", True):
merged["print_mode"] = "scheduled"
else:
merged["print_mode"] = "on"
# Clean up legacy keys
merged.pop("enabled", None)
merged.pop("quiet_enabled", None)
return merged
except Exception:
return defaults
Expand Down
25 changes: 15 additions & 10 deletions pi/webapp/server.py
Original file line number Diff line number Diff line change
Expand Up @@ -534,8 +534,11 @@ def validate_save_input(form) -> tuple[dict | None, list[str]]:
errors.append(f"Invalid theme. Must be one of: {', '.join(sorted(_VALID_THEMES))}.")
theme = "green"

# --- Quiet hours ---
quiet_enabled = form.get("quiet_enabled") == "1"
# --- Print mode (unified on/scheduled/off) ---
print_mode = form.get("print_mode", "scheduled")
if print_mode not in ("on", "scheduled", "off"):
errors.append("Print mode must be 'on', 'scheduled', or 'off'.")
print_mode = "scheduled"

quiet_start = form.get("quiet_start", "22:00").strip()
quiet_end = form.get("quiet_end", "08:00").strip()
Expand Down Expand Up @@ -588,7 +591,7 @@ def validate_save_input(form) -> tuple[dict | None, list[str]]:
"max_prints": max_prints,
"theme": theme,
"printer_device": printer_device,
"quiet_enabled": quiet_enabled,
"print_mode": print_mode,
"quiet_start": quiet_start,
"quiet_end": quiet_end,
"quiet_wake_mode": quiet_wake_mode,
Expand Down Expand Up @@ -627,11 +630,11 @@ def _quiet_hours_active() -> dict:
from datetime import datetime, time as dtime

config = load_config()
enabled = config.get("quiet_enabled", False)
print_mode = config.get("print_mode", "scheduled")
start_str = config.get("quiet_start", "22:00")
end_str = config.get("quiet_end", "08:00")

if not enabled:
if print_mode != "scheduled":
return {"enabled": False, "active": False, "start": start_str, "end": end_str}

now = datetime.now().time()
Expand Down Expand Up @@ -701,7 +704,7 @@ def save():
config["max_prints"] = validated["max_prints"]
config["theme"] = validated["theme"]
config["printer_device"] = validated["printer_device"]
config["quiet_enabled"] = validated["quiet_enabled"]
config["print_mode"] = validated["print_mode"]
config["quiet_start"] = validated["quiet_start"]
config["quiet_end"] = validated["quiet_end"]
config["quiet_wake_mode"] = validated["quiet_wake_mode"]
Expand Down Expand Up @@ -876,22 +879,24 @@ def status_api():
"printer": _printer_detected(),
"auto_update": _auto_update_state.copy(),
"quiet_hours": _quiet_hours_active(),
"enabled": cfg.get("enabled", True),
"print_mode": cfg.get("print_mode", "scheduled"),
})


@app.route("/toggle_enabled", methods=["POST"])
@require_auth
def toggle_enabled():
"""Toggle the print enabled/disabled state."""
"""Cycle print mode: on -> scheduled -> off -> on."""
client_ip = request.remote_addr or "unknown"
if _check_rate_limit(f"toggle:{client_ip}"):
abort(429)

config = load_config()
config["enabled"] = not config.get("enabled", True)
cycle = {"on": "scheduled", "scheduled": "off", "off": "on"}
current = config.get("print_mode", "scheduled")
config["print_mode"] = cycle.get(current, "on")
save_config(config)
logger.info("Printing %s via toggle button.", "enabled" if config["enabled"] else "disabled")
logger.info("Print mode changed to '%s' via toggle.", config["print_mode"])
return redirect(url_for("index"))


Expand Down
138 changes: 54 additions & 84 deletions pi/webapp/templates/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -52,43 +52,6 @@
font-size: 0.85em;
}

.print-toggle {
display: flex;
justify-content: center;
margin-bottom: 20px;
}

.print-toggle button {
font-size: 1.2em;
padding: 14px 40px;
letter-spacing: 2px;
font-weight: bold;
border-width: 3px;
transition: all 0.15s ease;
}

.print-toggle .toggle-on {
border-color: #33ff33;
color: #33ff33;
text-shadow: 0 0 8px #33ff33;
}
.print-toggle .toggle-on:hover {
background: #33ff33;
color: #0a0a0a;
text-shadow: none;
}

.print-toggle .toggle-off {
border-color: #ff3333;
color: #ff3333;
text-shadow: 0 0 8px #ff3333;
}
.print-toggle .toggle-off:hover {
background: #ff3333;
color: #0a0a0a;
text-shadow: none;
}

.status-bar {
display: flex;
justify-content: center;
Expand Down Expand Up @@ -247,18 +210,6 @@ <h1 style="white-space:nowrap;">[ PRINTPULSE ]</h1>
</div>
<div class="subtitle">Appliance Configuration</div>

<!-- Print On/Off Toggle -->
<div class="print-toggle">
<form action="/toggle_enabled" method="post">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
{% if config.enabled %}
<button type="submit" class="toggle-on" id="print-toggle-btn">[ PRINTING: ON ]</button>
{% else %}
<button type="submit" class="toggle-off" id="print-toggle-btn">[ PRINTING: OFF ]</button>
{% endif %}
</form>
</div>

<!-- Status Bar -->
<div class="status-bar">
<div class="status-item">
Expand All @@ -273,10 +224,10 @@ <h1 style="white-space:nowrap;">[ PRINTPULSE ]</h1>
id="prt-dot"></span>
<span>Printer: <strong id="prt-text">{% if printer_ok %}connected{% else %}not found{% endif %}</strong></span>
</div>
<div class="status-item" id="quiet-status" style="{% if not quiet_hours.enabled %}display:none;{% endif %}">
<span class="dot {% if quiet_hours.active %}dot-yellow{% else %}dot-green{% endif %}"
id="quiet-dot"></span>
<span>Quiet: <strong id="quiet-text">{% if quiet_hours.active %}active ({{ quiet_hours.start }}&ndash;{{ quiet_hours.end }}){% else %}off{% endif %}</strong></span>
<div class="status-item" id="print-mode-status">
<span class="dot {% if config.print_mode == 'off' %}dot-red{% elif config.print_mode == 'scheduled' and quiet_hours.active %}dot-yellow{% else %}dot-green{% endif %}"
id="mode-dot"></span>
<span>Print: <strong id="mode-text">{% if config.print_mode == 'off' %}off{% elif config.print_mode == 'on' %}on{% elif quiet_hours.active %}quiet ({{ quiet_hours.start }}&ndash;{{ quiet_hours.end }}){% else %}scheduled{% endif %}</strong></span>
</div>
</div>

Expand Down Expand Up @@ -331,14 +282,19 @@ <h1 style="white-space:nowrap;">[ PRINTPULSE ]</h1>
</div>

<div class="panel">
<div class="panel-title">// QUIET HOURS</div>
<div class="checkbox-row">
<input type="checkbox" name="quiet_enabled" id="quiet_enabled"
value="1" {% if config.quiet_enabled %}checked{% endif %}
onchange="document.getElementById('quiet-times').style.opacity = this.checked ? '1' : '0.4'">
<label for="quiet_enabled">Enable quiet hours</label>
<div class="panel-title">// PRINTING</div>
<label for="print_mode">Print mode:</label>
<select name="print_mode" id="print_mode" onchange="updatePrintMode()">
<option value="on" {% if config.print_mode == 'on' %}selected{% endif %}>Always on</option>
<option value="scheduled" {% if config.print_mode == 'scheduled' %}selected{% endif %}>Scheduled (quiet hours)</option>
<option value="off" {% if config.print_mode == 'off' %}selected{% endif %}>Off</option>
</select>
<div class="help" id="print-mode-help" style="margin-top:-10px;">
{% if config.print_mode == 'on' %}Prints whenever new items arrive.
{% elif config.print_mode == 'off' %}Printing is paused. Items are still tracked.
{% else %}Prints outside quiet hours. Items during quiet hours are queued.{% endif %}
</div>
<div id="quiet-times" style="opacity: {% if config.quiet_enabled %}1{% else %}0.4{% endif %}">
<div id="quiet-times" style="{% if config.print_mode != 'scheduled' %}opacity:0.4; pointer-events:none;{% endif %}">
<div class="row">
<div>
<label for="quiet_start">Start (no printing after):</label>
Expand Down Expand Up @@ -423,6 +379,25 @@ <h1 style="white-space:nowrap;">[ PRINTPULSE ]</h1>

<!-- Live status polling -->
<script>
function updatePrintMode() {
var mode = document.getElementById('print_mode').value;
var qt = document.getElementById('quiet-times');
var help = document.getElementById('print-mode-help');
if (mode === 'scheduled') {
qt.style.opacity = '1';
qt.style.pointerEvents = '';
help.textContent = 'Prints outside quiet hours. Items during quiet hours are queued.';
} else {
qt.style.opacity = '0.4';
qt.style.pointerEvents = 'none';
if (mode === 'on') {
help.textContent = 'Prints whenever new items arrive.';
} else {
help.textContent = 'Printing is paused. Items are still tracked.';
}
}
}

function doTestPrint() {
var btn = document.getElementById('test-print-btn');
var result = document.getElementById('test-print-result');
Expand Down Expand Up @@ -510,31 +485,26 @@ <h1 style="white-space:nowrap;">[ PRINTPULSE ]</h1>
}
}

// Print enabled/disabled toggle
var toggleBtn = document.getElementById('print-toggle-btn');
if (toggleBtn && data.enabled !== undefined) {
if (data.enabled) {
toggleBtn.textContent = '[ PRINTING: ON ]';
toggleBtn.className = 'toggle-on';
// Print mode + quiet hours status
var modeDot = document.getElementById('mode-dot');
var modeText = document.getElementById('mode-text');
if (modeDot && modeText && data.print_mode) {
var pm = data.print_mode;
var qh = data.quiet_hours || {};
if (pm === 'off') {
modeDot.className = 'dot dot-red';
modeText.textContent = 'off';
} else if (pm === 'on') {
modeDot.className = 'dot dot-green';
modeText.textContent = 'on';
} else {
toggleBtn.textContent = '[ PRINTING: OFF ]';
toggleBtn.className = 'toggle-off';
}
}

// Quiet hours status
if (data.quiet_hours) {
var qh = data.quiet_hours;
var qStatus = document.getElementById('quiet-status');
var qDot = document.getElementById('quiet-dot');
var qText = document.getElementById('quiet-text');
if (qStatus) {
qStatus.style.display = qh.enabled ? '' : 'none';
if (qh.enabled) {
qDot.className = 'dot ' + (qh.active ? 'dot-yellow' : 'dot-green');
qText.textContent = qh.active
? 'active (' + qh.start + '\u2013' + qh.end + ')'
: 'off';
// scheduled
if (qh.active) {
modeDot.className = 'dot dot-yellow';
modeText.textContent = 'quiet (' + qh.start + '\u2013' + qh.end + ')';
} else {
modeDot.className = 'dot dot-green';
modeText.textContent = 'scheduled';
}
}
}
Expand Down
2 changes: 1 addition & 1 deletion pi/webapp/wifi_routes.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@

import logging

from flask import Blueprint, render_template, request, redirect, jsonify, abort
from flask import Blueprint, render_template, request, jsonify, abort

logger = logging.getLogger("printpulse.wifi_routes")

Expand Down
9 changes: 5 additions & 4 deletions printpulse/pi_launcher.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,8 +20,9 @@ def main():

config = load_config()

if not config.get("enabled", True):
print("PrintPulse appliance is disabled in config. Exiting.")
print_mode = config.get("print_mode", "scheduled")
if print_mode == "off":
print("PrintPulse appliance is disabled (print_mode=off). Exiting.")
sys.exit(0)

feeds = config.get("feeds", [])
Expand Down Expand Up @@ -52,8 +53,8 @@ def main():
theme = config.get("theme", "green")
argv.extend(["--theme", theme])

# Quiet hours
if config.get("quiet_enabled", True):
# Quiet hours (only in scheduled mode)
if print_mode == "scheduled":
quiet_start = config.get("quiet_start", "22:00")
quiet_end = config.get("quiet_end", "08:00")
argv.extend(["--quiet-start", quiet_start, "--quiet-end", quiet_end])
Expand Down
48 changes: 47 additions & 1 deletion tests/test_appliance.py
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@ def test_has_required_keys(self):
assert "max_prints" in cfg
assert "theme" in cfg
assert "printer_device" in cfg
assert "enabled" in cfg
assert "print_mode" in cfg

def test_has_auth_fields(self):
cfg = default_config()
Expand All @@ -43,6 +43,52 @@ def test_default_feed_is_https(self):
for feed in cfg["feeds"]:
assert feed.startswith("https://")

def test_print_mode_default(self):
cfg = default_config()
assert cfg["print_mode"] == "scheduled"


class TestConfigMigration:
"""Test migration from legacy enabled/quiet_enabled to print_mode."""

def test_legacy_disabled_migrates_to_off(self, tmp_path, monkeypatch):
import json
from pi import appliance
cfg_path = tmp_path / "config.json"
cfg_path.write_text(json.dumps({"enabled": False, "quiet_enabled": True}))
monkeypatch.setattr(appliance, "CONFIG_PATH", str(cfg_path))
cfg = appliance.load_config()
assert cfg["print_mode"] == "off"
assert "enabled" not in cfg
assert "quiet_enabled" not in cfg

def test_legacy_enabled_quiet_on_migrates_to_scheduled(self, tmp_path, monkeypatch):
import json
from pi import appliance
cfg_path = tmp_path / "config.json"
cfg_path.write_text(json.dumps({"enabled": True, "quiet_enabled": True}))
monkeypatch.setattr(appliance, "CONFIG_PATH", str(cfg_path))
cfg = appliance.load_config()
assert cfg["print_mode"] == "scheduled"

def test_legacy_enabled_quiet_off_migrates_to_on(self, tmp_path, monkeypatch):
import json
from pi import appliance
cfg_path = tmp_path / "config.json"
cfg_path.write_text(json.dumps({"enabled": True, "quiet_enabled": False}))
monkeypatch.setattr(appliance, "CONFIG_PATH", str(cfg_path))
cfg = appliance.load_config()
assert cfg["print_mode"] == "on"

def test_new_config_no_migration(self, tmp_path, monkeypatch):
import json
from pi import appliance
cfg_path = tmp_path / "config.json"
cfg_path.write_text(json.dumps({"print_mode": "off"}))
monkeypatch.setattr(appliance, "CONFIG_PATH", str(cfg_path))
cfg = appliance.load_config()
assert cfg["print_mode"] == "off"


class TestPasswordHashing:
def test_hash_produces_pbkdf2_format(self):
Expand Down
Loading
Loading