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
5 changes: 5 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,11 @@ Koenig…
the systemd service. The container sees the host's `/dev` (opening only the printer), so a printer
turned off and on again, or plugged in after startup, is still found. Both files ship in the release
archives, next to the binary.
- **Show the ticket configuration**, in the composer (admin only): the ticket as YAML for Home Assistant, ready to paste
into an automation or a script — the `mqtt.publish` action on `printr/print` when Printr is connected to
MQTT, or the `printr.print_ticket` action of the Printr integration — or as JSON for the HTTP API, MQTT
and `printr print`. Copying also works over plain HTTP on the local
network, where browsers refuse the clipboard API. `/api/status` gives the topic prefix (`mqtt_prefix`).

## [1.10.0] “Koenig” - 2026-10-21

Expand Down
6 changes: 6 additions & 0 deletions docs/guide.fr.md
Original file line number Diff line number Diff line change
Expand Up @@ -414,6 +414,12 @@ papier baisse. La découverte utilise le préfixe `homeassistant` ; `PRINTR_MQTT
(ou la coupe avec `off`). Deux Printr sur un même broker doivent avoir des `PRINTR_MQTT_PREFIX`
différents : chacun est son propre appareil.

**Un ticket de l'appli dans une automatisation** : dans le composeur, « Afficher la configuration du
ticket » (administrateur seulement) donne le ticket en YAML, prêt à coller dans une automatisation ou un script (édition en
YAML), ou en JSON pour l'API HTTP, MQTT et `printr print`. En YAML, deux formes : l'action
`mqtt.publish` qui publie le ticket sur `printr/print`, proposée quand Printr est relié à MQTT, ou
l'action `printr.print_ticket` de l'[intégration Printr](https://github.com/Matteo-Penaud/printr-ha-integration).

### Certificats TLS

`mqtt://` (sans TLS) fonctionne avec n'importe quel broker de ton réseau. `mqtts://` vérifie le
Expand Down
6 changes: 6 additions & 0 deletions docs/guide.md
Original file line number Diff line number Diff line change
Expand Up @@ -412,6 +412,12 @@ So an automation can print a ticket when the doorbell rings, or warn the phone w
Discovery uses the `homeassistant` prefix; `PRINTR_MQTT_DISCOVERY` changes it (or turns it `off`).
Two Printr on the same broker need different `PRINTR_MQTT_PREFIX` values: each one is its own device.

**A ticket from the app in an automation**: in the composer, “Show the ticket configuration” (admin only) gives
the ticket as YAML, ready to paste into an automation or a script (YAML mode), or as JSON for the HTTP API,
MQTT and `printr print`. In YAML, two forms: the `mqtt.publish` action publishing the ticket on
`printr/print`, offered when Printr is connected to MQTT, or the `printr.print_ticket` action of the
[Printr integration](https://github.com/Matteo-Penaud/printr-ha-integration).

### TLS certificates

`mqtt://` (no TLS) works with any broker on your network. `mqtts://` checks the broker's certificate:
Expand Down
29 changes: 23 additions & 6 deletions src/mqtt.rs
Original file line number Diff line number Diff line change
Expand Up @@ -245,17 +245,27 @@ fn client_id() -> String {
if host.is_empty() { "printr".to_owned() } else { format!("printr-{host}") }
}

fn url() -> Option<String> {
std::env::var("PRINTR_MQTT_URL").ok().filter(|u| !u.trim().is_empty())
}

/// Préfixe des sujets (`PRINTR_MQTT_PREFIX`, `printr` par défaut), si MQTT est configuré : l'appli
/// web s'en sert pour écrire l'action Home Assistant qui publie un ticket.
pub fn prefix() -> Option<String> {
url()?;
Some(clean_prefix(std::env::var("PRINTR_MQTT_PREFIX").ok().as_deref()))
}

fn clean_prefix(setting: Option<&str>) -> String {
setting.map(|p| p.trim().trim_matches('/')).filter(|p| !p.is_empty()).unwrap_or("printr").to_owned()
}

/// Lance le client MQTT si `PRINTR_MQTT_URL` est défini. Une adresse invalide est signalée, sans
/// empêcher le serveur de tourner.
pub fn spawn(app: Arc<App>) {
let Some(url) = std::env::var("PRINTR_MQTT_URL").ok().filter(|u| !u.trim().is_empty()) else {
let (Some(url), Some(prefix)) = (url(), prefix()) else {
return;
};
let prefix = std::env::var("PRINTR_MQTT_PREFIX")
.ok()
.map(|p| p.trim().trim_matches('/').to_owned())
.filter(|p| !p.is_empty())
.unwrap_or_else(|| "printr".to_owned());
let options = match parse_url(&url).and_then(|broker| options(&broker, &prefix)) {
Ok(options) => options,
Err(e) => {
Expand Down Expand Up @@ -473,6 +483,13 @@ fn discover(app: &App, client: &Client, prefix: &str, discovery: &str, events: &
mod tests {
use super::*;

#[test]
fn prefix_is_trimmed_with_a_default() {
assert_eq!(clean_prefix(None), "printr");
assert_eq!(clean_prefix(Some(" ")), "printr");
assert_eq!(clean_prefix(Some("/maison/printr/ ")), "maison/printr");
}

#[test]
fn broker_url() {
assert_eq!(
Expand Down
1 change: 1 addition & 0 deletions src/server.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1141,6 +1141,7 @@ fn handle(app: &App, request: &mut Request) -> Reply {
"claude": claude.as_ref().map(|c| c.model().to_owned()),
"claude_backend": claude.as_ref().map(|c| c.backend()),
"version": env!("CARGO_PKG_VERSION"),
"mqtt_prefix": crate::mqtt::prefix(),
"update": *app.latest.lock().unwrap_or_else(|e| e.into_inner()),
}))
}
Expand Down
106 changes: 104 additions & 2 deletions web/app.js
Original file line number Diff line number Diff line change
Expand Up @@ -165,6 +165,7 @@ const ICONS = {
send: '<path d="M22 2 11 13M22 2l-7 20-4-9-9-4z"/>',
search: '<circle cx="11" cy="11" r="7"/><path d="m20 20-3.5-3.5"/>',
spark: '<path d="M12 3l1.8 5.2L19 10l-5.2 1.8L12 17l-1.8-5.2L5 10l5.2-1.8z"/>',
'file-edit': '<path d="M10 21H6a1 1 0 0 1-1-1V4a1 1 0 0 1 1-1h8l5 5v2"/><path d="M14 3v5h5M8 12h6M8 16h3"/><path d="m18.5 13 2.5 2.5-6 6H12.5V19z"/>',
scissors: '<circle cx="6" cy="7" r="3"/><circle cx="6" cy="17" r="3"/><path d="M8.6 8.5 21 16.5M8.6 15.5 21 7.5"/>',
};

Expand Down Expand Up @@ -758,6 +759,47 @@ function cleanBlock(data) {
}

const toTicket = (state) => ({ cut: state.cut, spacing: Number(state.spacing), blocks: state.blocks.map((b) => cleanBlock(b.data)) });

// YAML d'un ticket, pour Home Assistant. Une chaîne reste nue si rien ne peut la faire lire
// autrement : sinon entre apostrophes (guillemets JSON s'il y a des caractères de contrôle), pour
// « yes », « on », les nombres, les dates (2026-12-25 deviendrait une date), « : » ou « # ».
const YAML_PLAIN = /^[\p{L}_][^\n:#]*$/u;
const YAML_RESERVED = /^(true|false|yes|no|on|off|y|n|null|~)$/i;
const isMap = (v) => v !== null && typeof v === 'object' && !Array.isArray(v);

function yamlScalar(v, indent) {
if (v === null || v === undefined) return 'null';
if (typeof v !== 'string') return String(v);
// Texte sur plusieurs lignes : bloc littéral, lignes gardées telles quelles.
if (v.includes('\n') && !/^\s/.test(v) && !v.includes('\r')) {
const chomp = v.endsWith('\n\n') ? '+' : v.endsWith('\n') ? '' : '-';
const lines = v.replace(/\n$/, '').split('\n');
return `|${chomp}\n${lines.map((l) => (l ? indent + l : '')).join('\n')}`;
}
if (YAML_PLAIN.test(v) && !/[\x00-\x1f\x7f]|\s$/.test(v) && !YAML_RESERVED.test(v)) return v;
return /[\x00-\x08\x0a-\x1f\x7f]/.test(v) ? JSON.stringify(v) : `'${v.replaceAll("'", "''")}'`;
}

// Valeur qui suit « clé: » ou « - » : sur la même ligne, ou en dessous, indentée de `indent`.
function yamlNode(v, indent) {
if (Array.isArray(v)) return v.length ? `\n${v.map((x) => `${indent}-${yamlItem(x, `${indent} `)}`).join('\n')}` : ' []';
if (isMap(v)) return Object.keys(v).length ? `\n${yamlMap(v, indent)}` : ' {}';
return ` ${yamlScalar(v, indent)}`;
}
const yamlMap = (obj, indent) =>
Object.entries(obj).map(([k, v]) => `${indent}${yamlScalar(k, indent)}:${yamlNode(v, `${indent} `)}`).join('\n');
// Élément de liste : un objet commence sur la ligne du tiret.
const yamlItem = (v, indent) => (isMap(v) && Object.keys(v).length ? ` ${yamlMap(v, indent).slice(indent.length)}` : yamlNode(v, indent));

// Action de l'intégration Printr pour Home Assistant, ou action MQTT standard qui publie le ticket
// sur le sujet d'impression (`prefix` : PRINTR_MQTT_PREFIX du serveur).
const haYaml = (ticket, label) =>
`${yamlMap({ action: 'printr.print_ticket', data: { ...(label ? { label } : {}), ticket } }, '')}\n`;
const mqttYaml = (ticket, prefix) =>
`${yamlMap({ action: 'mqtt.publish', data: { topic: `${prefix}/print`, payload: JSON.stringify(ticket) } }, '')}\n`;

// Réglages du serveur utiles à l'appli, relus avec /api/status.
const SERVER = { mqtt: null };
const usesAi = (blocks) => blocks.some((b) => BLOCK[b.type || b.data?.type]?.ai);

function blockSummary(data) {
Expand Down Expand Up @@ -1755,11 +1797,67 @@ function SaveSheet({ state, onSave, onClose, focusSchedule }) {
</${Sheet}>`;
}

function Composer({ initial, onSaved }) {
// Presse-papiers : l'API moderne n'existe qu'en HTTPS ou sur localhost, et l'appli est souvent
// servie en HTTP sur le réseau local ; repli par le texte sélectionné dans la zone `area`, aussi
// quand le navigateur refuse l'accès au presse-papiers.
async function copyText(text, area) {
try {
if (window.isSecureContext && navigator.clipboard) {
await navigator.clipboard.writeText(text);
return true;
}
} catch { /* refusé : repli ci-dessous */ }
area.focus();
area.select();
return document.execCommand('copy');
}

// Configuration du ticket composé, à copier : en YAML, l'action Home Assistant (intégration Printr,
// ou MQTT quand le serveur y est relié) ; en JSON, le ticket tel que le prennent l'API, MQTT et
// `printr print`. Le dernier format choisi est gardé.
function TicketConfigSheet({ ticket, label, onClose }) {
const [format, setFormatState] = useState(() => store.get('printr.config.format', 'yaml'));
const setFormat = (f) => { setFormatState(f); store.set('printr.config.format', f); };
const [via, setVia] = useState('integration');
const mqtt = format === 'yaml' && via === 'mqtt' && SERVER.mqtt;
const text = useMemo(() => {
if (format === 'json') return `${JSON.stringify(ticket, null, 2)}\n`;
return mqtt ? mqttYaml(ticket, mqtt) : haYaml(ticket, label);
}, [format, ticket, label, mqtt]);
const area = useRef(null);
const copy = async () => {
if (await copyText(text, area.current)) return toast(T('Configuration copiée', 'Configuration copied'), 'ok', '📋');
area.current.select();
toast(T('Copie impossible : le texte est sélectionné, copie-le à la main', 'Could not copy: the text is selected, copy it by hand'), 'error');
};
const help = format === 'json'
? T('Le ticket en JSON, tel que le prennent l’API HTTP (/print), MQTT (printr/print) et printr print ticket.json.',
'The ticket as JSON, as taken by the HTTP API (/print), MQTT (printr/print) and printr print ticket.json.')
: `${mqtt
? T(`L’action mqtt.publish de Home Assistant, qui publie le ticket sur ${mqtt}/print : rien d’autre à installer que l’intégration MQTT.`,
`Home Assistant’s mqtt.publish action, publishing the ticket on ${mqtt}/print: nothing to install but the MQTT integration.`)
: T('L’action printr.print_ticket de l’intégration Printr pour Home Assistant.', 'The printr.print_ticket action of the Printr integration for Home Assistant.')
} ${T('À coller dans une automatisation ou un script, en édition YAML.', 'Paste it into an automation or a script, in YAML mode.')}`;
return html`<${Sheet} title=${T('Configuration du ticket', 'Ticket configuration')} wide onClose=${onClose} footer=${html`
<button class="btn secondary" onClick=${onClose}>${T('Fermer', 'Close')}</button>
<button class="btn primary grow" onClick=${copy}><${Icon} name="copy" /> ${T('Copier', 'Copy')}</button>`}>
<div class="config-switches">
<${Segmented} options=${[['yaml', 'YAML'], ['json', 'JSON']]} value=${format} onChange=${setFormat} />
${format === 'yaml' && SERVER.mqtt && html`<${Segmented} options=${[['integration', T('Intégration Printr', 'Printr integration')], ['mqtt', 'MQTT']]}
value=${via} onChange=${setVia} />`}
</div>
<p class="muted">${help}</p>
<textarea class="input code" ref=${area} readonly spellcheck=${false} aria-label=${format.toUpperCase()}
rows=${Math.min(18, text.split('\n').length)} value=${text}></textarea>
</${Sheet}>`;
}

function Composer({ initial, onSaved, admin }) {
const [state, setState] = useState(initial);
// Catalogue ouvert : `{ at }` = position d'insertion (à la fin si absente).
const [catalog, setCatalog] = useState(null);
const [saveSheet, setSaveSheet] = useState(null);
const [configSheet, setConfigSheet] = useState(false);
const [mobilePreview, setMobilePreview] = useState(false);
const [printing, setPrinting] = useState(false);
const [saving, setSaving] = useState(false);
Expand Down Expand Up @@ -1846,6 +1944,8 @@ function Composer({ initial, onSaved }) {
<${Switch} label=${T('Couper le papier à la fin', 'Cut the paper at the end')} checked=${state.cut} onChange=${(cut) => setState((s) => ({ ...s, cut }))} />
<div class="spacing-row"><span class="label">${T('Espace entre les blocs', 'Space between blocks')}</span>
<${Segmented} options=${[[0, T('Aucun', 'None')], [1, T('1 ligne', '1 line')], [2, T('2 lignes', '2 lines')]]} value=${Number(state.spacing)} onChange=${(spacing) => setState((s) => ({ ...s, spacing }))} /></div>
${admin && html`<button class="btn ghost small show-config" disabled=${!state.blocks.length} onClick=${() => setConfigSheet(true)}>
<${Icon} name="file-edit" size=${16} /> ${T('Afficher la configuration du ticket', 'Show the ticket configuration')}</button>`}
</div>
<div class="actionbar">
<button class="btn ghost preview-fab" aria-label=${T('Aperçu', 'Preview')} onClick=${() => setMobilePreview(true)}><${Icon} name="eye" /><span class="lbl">${T('Aperçu', 'Preview')}</span></button>
Expand All @@ -1856,6 +1956,7 @@ function Composer({ initial, onSaved }) {
<aside class="preview-col"><${Paper} preview=${preview} loading=${loading} /><${PaperMeta} preview=${preview} /></aside>
</div>
${catalog && html`<${Catalog} onPick=${(type) => add(type, catalog.at)} onClose=${() => setCatalog(null)} />`}
${configSheet && html`<${TicketConfigSheet} ticket=${ticket} label=${state.name} onClose=${() => setConfigSheet(false)} />`}
${saveSheet && html`<${SaveSheet} state=${state} focusSchedule=${saveSheet.focusSchedule} onSave=${persist} onClose=${() => setSaveSheet(null)} />`}
${mobilePreview && html`<${Sheet} title=${T('Aperçu', 'Preview')} onClose=${() => setMobilePreview(false)} footer=${html`
<button class="btn primary grow" disabled=${printing} onClick=${print}>${printing ? html`<${Spinner} />` : html`<${Icon} name="printer" />`} ${T('Imprimer', 'Print')}</button>`}>
Expand Down Expand Up @@ -2259,6 +2360,7 @@ function App() {
setVersion(s.version); setUpdate(s.update || null);
CATALOG.hidden = s.hidden_blocks || [];
CATALOG.claude = !!s.claude;
SERVER.mqtt = s.mqtt_prefix || null;
setPrinter((before) => {
const status = s.printer_status || (s.printer_ready === false ? 'missing' : 'ready');
const now = { ready: status === 'ready', status, queued: s.queued || 0, paper: s.paper || null };
Expand Down Expand Up @@ -2292,7 +2394,7 @@ function App() {
: route.title;

let page;
if (route.hash === '#/compose') page = html`<${Composer} initial=${composer} onSaved=${loadPresets} />`;
if (route.hash === '#/compose') page = html`<${Composer} initial=${composer} onSaved=${loadPresets} admin=${me.admin} />`;
else if (route.hash === '#/message') page = html`<${Message} me=${me} onSaved=${loadPresets} />`;
else if (route.hash === '#/history') page = html`<${History} users=${users} me=${me} />`;
else if (route.hash === '#/courses') page = html`<${Shopping} users=${users} />`;
Expand Down
3 changes: 3 additions & 0 deletions web/style.css
Original file line number Diff line number Diff line change
Expand Up @@ -400,6 +400,9 @@ select.input { appearance: none; padding-right: 38px;
@media (min-width: 1100px) { .preview-fab { display: none; } }

.ticket-settings { padding: 14px 16px; margin-top: 10px; }
.ticket-settings .show-config { margin: 10px -6px -4px; }
.config-switches { display: grid; gap: 8px; }
textarea.input.code { font-family: var(--mono); font-size: 13px; line-height: 1.45; white-space: pre; overflow: auto; min-height: 220px; }
.spacing-row { display: flex; flex-wrap: wrap; align-items: center; justify-content: space-between; gap: 8px 12px; margin-top: 6px; }
@media (max-width: 480px) { .spacing-row .segmented { width: 100%; } }
.actionbar .btn { min-width: 0; }
Expand Down
Loading