feat(picobrew): Add PicoBrew Server packages for OpenWrt

Add two new packages for self-hosted brewing controller support:

secubox-app-picobrew:
- LXC container-based PicoBrew Server installation
- Alpine Linux rootfs with Python/Flask environment
- UCI configuration for port, memory, brewing defaults
- procd service management with respawn
- Commands: install, uninstall, update, status, logs, shell

luci-app-picobrew:
- Modern dashboard UI with SecuBox styling
- Service controls (start/stop/restart/install/update)
- Real-time status monitoring and logs
- Settings page for server and brewing configuration
- RPCD backend with full API coverage

Supports PicoBrew Zymatic, Z, Pico C, and Pico Pro devices.
Repository: https://github.com/CyberMind-FR/picobrew-server

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
CyberMind-FR 2026-01-23 12:53:31 +01:00
parent 14690ebe9e
commit b69a84394b
10 changed files with 2041 additions and 0 deletions

View File

@ -0,0 +1,29 @@
# SPDX-License-Identifier: Apache-2.0
#
# Copyright (C) 2025 CyberMind.fr
#
# LuCI PicoBrew Dashboard
include $(TOPDIR)/rules.mk
PKG_NAME:=luci-app-picobrew
PKG_VERSION:=1.0.0
PKG_RELEASE:=1
PKG_ARCH:=all
PKG_LICENSE:=Apache-2.0
PKG_MAINTAINER:=CyberMind <contact@cybermind.fr>
LUCI_TITLE:=LuCI PicoBrew Dashboard
LUCI_DESCRIPTION:=Modern dashboard for PicoBrew Server management on OpenWrt
LUCI_DEPENDS:=+luci-base +luci-lib-jsonc +rpcd +rpcd-mod-luci +secubox-app-picobrew
LUCI_PKGARCH:=all
PKG_FILE_MODES:=/usr/libexec/rpcd/luci.picobrew:root:root:755
include $(TOPDIR)/feeds/luci/luci.mk
# Note: /etc/config/picobrew is in secubox-app-picobrew
$(eval $(call BuildPackage,luci-app-picobrew))

View File

@ -0,0 +1,803 @@
'use strict';
'require view';
'require ui';
'require dom';
'require poll';
'require rpc';
// RPC declarations
var callGetStatus = rpc.declare({
object: 'luci.picobrew',
method: 'get_status',
expect: { result: {} }
});
var callGetConfig = rpc.declare({
object: 'luci.picobrew',
method: 'get_config',
expect: { result: {} }
});
var callStart = rpc.declare({
object: 'luci.picobrew',
method: 'start',
expect: { result: {} }
});
var callStop = rpc.declare({
object: 'luci.picobrew',
method: 'stop',
expect: { result: {} }
});
var callRestart = rpc.declare({
object: 'luci.picobrew',
method: 'restart',
expect: { result: {} }
});
var callInstall = rpc.declare({
object: 'luci.picobrew',
method: 'install',
expect: { result: {} }
});
var callUninstall = rpc.declare({
object: 'luci.picobrew',
method: 'uninstall',
expect: { result: {} }
});
var callUpdate = rpc.declare({
object: 'luci.picobrew',
method: 'update',
expect: { result: {} }
});
var callGetLogs = rpc.declare({
object: 'luci.picobrew',
method: 'get_logs',
params: ['lines'],
expect: { result: {} }
});
var callGetInstallProgress = rpc.declare({
object: 'luci.picobrew',
method: 'get_install_progress',
expect: { result: {} }
});
var callGetSessions = rpc.declare({
object: 'luci.picobrew',
method: 'get_sessions',
expect: { result: {} }
});
var callGetRecipes = rpc.declare({
object: 'luci.picobrew',
method: 'get_recipes',
expect: { result: {} }
});
// CSS styles
var styles = `
.picobrew-dashboard {
padding: 20px;
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
}
.pb-header {
background: linear-gradient(135deg, rgba(6, 182, 212, 0.1), rgba(139, 92, 246, 0.1));
border: 1px solid rgba(6, 182, 212, 0.3);
border-radius: 12px;
padding: 24px;
margin-bottom: 24px;
}
.pb-header-content {
display: flex;
align-items: center;
gap: 20px;
}
.pb-logo {
font-size: 48px;
}
.pb-title {
margin: 0;
font-size: 24px;
color: #06b6d4;
}
.pb-subtitle {
margin: 4px 0 0 0;
color: #94a3b8;
font-size: 14px;
}
.pb-stats-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
gap: 16px;
margin-bottom: 24px;
}
.pb-stat-card {
background: rgba(15, 23, 42, 0.8);
border: 1px solid rgba(51, 65, 85, 0.5);
border-radius: 12px;
padding: 20px;
display: flex;
align-items: center;
gap: 16px;
}
.pb-stat-card.success { border-color: rgba(16, 185, 129, 0.5); }
.pb-stat-card.warning { border-color: rgba(245, 158, 11, 0.5); }
.pb-stat-card.error { border-color: rgba(244, 63, 94, 0.5); }
.pb-stat-icon {
font-size: 32px;
width: 48px;
height: 48px;
display: flex;
align-items: center;
justify-content: center;
background: rgba(6, 182, 212, 0.1);
border-radius: 10px;
}
.pb-stat-content {
flex: 1;
}
.pb-stat-value {
font-size: 24px;
font-weight: 600;
color: #f1f5f9;
}
.pb-stat-label {
font-size: 13px;
color: #94a3b8;
margin-top: 2px;
}
.pb-main-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 24px;
}
@media (max-width: 900px) {
.pb-main-grid {
grid-template-columns: 1fr;
}
}
.pb-card {
background: rgba(15, 23, 42, 0.8);
border: 1px solid rgba(51, 65, 85, 0.5);
border-radius: 12px;
overflow: hidden;
}
.pb-card-header {
padding: 16px 20px;
border-bottom: 1px solid rgba(51, 65, 85, 0.5);
display: flex;
align-items: center;
justify-content: space-between;
}
.pb-card-title {
font-size: 16px;
font-weight: 600;
color: #f1f5f9;
display: flex;
align-items: center;
gap: 8px;
}
.pb-card-body {
padding: 20px;
}
.pb-btn {
padding: 10px 20px;
border-radius: 8px;
font-size: 14px;
font-weight: 500;
cursor: pointer;
border: none;
transition: all 0.2s;
display: inline-flex;
align-items: center;
gap: 8px;
}
.pb-btn-primary {
background: linear-gradient(135deg, #06b6d4, #0891b2);
color: white;
}
.pb-btn-primary:hover {
transform: translateY(-1px);
box-shadow: 0 4px 12px rgba(6, 182, 212, 0.4);
}
.pb-btn-success {
background: linear-gradient(135deg, #10b981, #059669);
color: white;
}
.pb-btn-danger {
background: linear-gradient(135deg, #f43f5e, #e11d48);
color: white;
}
.pb-btn-warning {
background: linear-gradient(135deg, #f59e0b, #d97706);
color: white;
}
.pb-btn:disabled {
opacity: 0.5;
cursor: not-allowed;
transform: none !important;
}
.pb-btn-group {
display: flex;
gap: 10px;
flex-wrap: wrap;
}
.pb-status-badge {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 6px 12px;
border-radius: 20px;
font-size: 13px;
font-weight: 500;
}
.pb-status-badge.running {
background: rgba(16, 185, 129, 0.2);
color: #10b981;
}
.pb-status-badge.stopped {
background: rgba(244, 63, 94, 0.2);
color: #f43f5e;
}
.pb-status-badge.not-installed {
background: rgba(245, 158, 11, 0.2);
color: #f59e0b;
}
.pb-info-list {
list-style: none;
padding: 0;
margin: 0;
}
.pb-info-list li {
display: flex;
justify-content: space-between;
padding: 10px 0;
border-bottom: 1px solid rgba(51, 65, 85, 0.3);
}
.pb-info-list li:last-child {
border-bottom: none;
}
.pb-info-label {
color: #94a3b8;
}
.pb-info-value {
color: #f1f5f9;
font-weight: 500;
}
.pb-info-value a {
color: #06b6d4;
text-decoration: none;
}
.pb-info-value a:hover {
text-decoration: underline;
}
.pb-logs {
background: #0f172a;
border-radius: 8px;
padding: 12px;
font-family: "Monaco", "Consolas", monospace;
font-size: 12px;
color: #94a3b8;
max-height: 300px;
overflow-y: auto;
}
.pb-logs-line {
margin: 4px 0;
white-space: pre-wrap;
word-break: break-all;
}
.pb-progress {
background: rgba(51, 65, 85, 0.5);
border-radius: 8px;
height: 8px;
overflow: hidden;
margin: 16px 0;
}
.pb-progress-bar {
height: 100%;
background: linear-gradient(90deg, #06b6d4, #8b5cf6);
border-radius: 8px;
transition: width 0.3s ease;
}
.pb-progress-text {
text-align: center;
color: #94a3b8;
font-size: 13px;
margin-top: 8px;
}
.pb-empty {
text-align: center;
padding: 40px 20px;
color: #64748b;
}
.pb-empty-icon {
font-size: 48px;
margin-bottom: 12px;
}
`;
return view.extend({
statusData: null,
configData: null,
logsData: null,
installProgress: null,
sessionsData: null,
recipesData: null,
load: function() {
return this.refreshData();
},
refreshData: function() {
var self = this;
return Promise.all([
callGetStatus(),
callGetConfig(),
callGetLogs(50),
callGetSessions(),
callGetRecipes()
]).then(function(data) {
self.statusData = data[0] || {};
self.configData = data[1] || {};
self.logsData = data[2] || {};
self.sessionsData = data[3] || {};
self.recipesData = data[4] || {};
return data;
});
},
render: function() {
var self = this;
// Inject styles
var styleEl = E('style', {}, styles);
var container = E('div', { 'class': 'picobrew-dashboard' }, [
styleEl,
this.renderHeader(),
this.renderStatsGrid(),
this.renderMainGrid()
]);
// Poll for updates
poll.add(function() {
return self.refreshData().then(function() {
self.updateDynamicContent();
});
}, 10);
return container;
},
renderHeader: function() {
var status = this.statusData;
var statusClass = !status.installed ? 'not-installed' : (status.running ? 'running' : 'stopped');
var statusText = !status.installed ? _('Not Installed') : (status.running ? _('Running') : _('Stopped'));
return E('div', { 'class': 'pb-header' }, [
E('div', { 'class': 'pb-header-content' }, [
E('div', { 'class': 'pb-logo' }, '🍺'),
E('div', {}, [
E('h1', { 'class': 'pb-title' }, _('PicoBrew Server')),
E('p', { 'class': 'pb-subtitle' }, _('Self-hosted brewing controller for PicoBrew devices'))
]),
E('div', { 'class': 'pb-status-badge ' + statusClass, 'id': 'pb-status-badge' }, [
E('span', {}, statusClass === 'running' ? '●' : '○'),
statusText
])
])
]);
},
renderStatsGrid: function() {
var status = this.statusData;
var sessions = (this.sessionsData.sessions || []).length;
var recipes = (this.recipesData.recipes || []).length;
var stats = [
{
icon: '🔌',
label: _('Status'),
value: status.running ? _('Online') : _('Offline'),
id: 'stat-status',
cardClass: status.running ? 'success' : 'error'
},
{
icon: '🌐',
label: _('Port'),
value: status.http_port || '8080',
id: 'stat-port'
},
{
icon: '📊',
label: _('Sessions'),
value: sessions,
id: 'stat-sessions'
},
{
icon: '📖',
label: _('Recipes'),
value: recipes,
id: 'stat-recipes'
}
];
return E('div', { 'class': 'pb-stats-grid' },
stats.map(function(stat) {
return E('div', { 'class': 'pb-stat-card ' + (stat.cardClass || '') }, [
E('div', { 'class': 'pb-stat-icon' }, stat.icon),
E('div', { 'class': 'pb-stat-content' }, [
E('div', { 'class': 'pb-stat-value', 'id': stat.id }, String(stat.value)),
E('div', { 'class': 'pb-stat-label' }, stat.label)
])
]);
})
);
},
renderMainGrid: function() {
return E('div', { 'class': 'pb-main-grid' }, [
this.renderControlCard(),
this.renderInfoCard(),
this.renderLogsCard()
]);
},
renderControlCard: function() {
var self = this;
var status = this.statusData;
var buttons = [];
if (!status.installed) {
buttons.push(
E('button', {
'class': 'pb-btn pb-btn-primary',
'id': 'btn-install',
'click': function() { self.handleInstall(); }
}, [E('span', {}, '📥'), _('Install')])
);
} else {
if (status.running) {
buttons.push(
E('button', {
'class': 'pb-btn pb-btn-danger',
'id': 'btn-stop',
'click': function() { self.handleStop(); }
}, [E('span', {}, '⏹'), _('Stop')])
);
buttons.push(
E('button', {
'class': 'pb-btn pb-btn-warning',
'id': 'btn-restart',
'click': function() { self.handleRestart(); }
}, [E('span', {}, '🔄'), _('Restart')])
);
} else {
buttons.push(
E('button', {
'class': 'pb-btn pb-btn-success',
'id': 'btn-start',
'click': function() { self.handleStart(); }
}, [E('span', {}, '▶'), _('Start')])
);
}
buttons.push(
E('button', {
'class': 'pb-btn pb-btn-primary',
'id': 'btn-update',
'click': function() { self.handleUpdate(); }
}, [E('span', {}, '⬆'), _('Update')])
);
buttons.push(
E('button', {
'class': 'pb-btn pb-btn-danger',
'id': 'btn-uninstall',
'click': function() { self.handleUninstall(); }
}, [E('span', {}, '🗑'), _('Uninstall')])
);
}
return E('div', { 'class': 'pb-card' }, [
E('div', { 'class': 'pb-card-header' }, [
E('div', { 'class': 'pb-card-title' }, [
E('span', {}, '🎮'),
_('Controls')
])
]),
E('div', { 'class': 'pb-card-body' }, [
E('div', { 'class': 'pb-btn-group', 'id': 'pb-controls' }, buttons),
E('div', { 'class': 'pb-progress', 'id': 'pb-progress-container', 'style': 'display:none' }, [
E('div', { 'class': 'pb-progress-bar', 'id': 'pb-progress-bar', 'style': 'width:0%' })
]),
E('div', { 'class': 'pb-progress-text', 'id': 'pb-progress-text', 'style': 'display:none' })
])
]);
},
renderInfoCard: function() {
var status = this.statusData;
var infoItems = [
{ label: _('Container'), value: status.container_name || 'picobrew' },
{ label: _('Data Path'), value: status.data_path || '/srv/picobrew' },
{ label: _('Memory Limit'), value: status.memory_limit || '512M' },
{ label: _('Web Interface'), value: status.web_url, isLink: true }
];
return E('div', { 'class': 'pb-card' }, [
E('div', { 'class': 'pb-card-header' }, [
E('div', { 'class': 'pb-card-title' }, [
E('span', {}, ''),
_('Information')
])
]),
E('div', { 'class': 'pb-card-body' }, [
E('ul', { 'class': 'pb-info-list', 'id': 'pb-info-list' },
infoItems.map(function(item) {
var valueEl;
if (item.isLink && item.value) {
valueEl = E('a', { 'href': item.value, 'target': '_blank' }, item.value);
} else {
valueEl = item.value || '-';
}
return E('li', {}, [
E('span', { 'class': 'pb-info-label' }, item.label),
E('span', { 'class': 'pb-info-value' }, valueEl)
]);
})
)
])
]);
},
renderLogsCard: function() {
var logs = this.logsData.logs || [];
return E('div', { 'class': 'pb-card', 'style': 'grid-column: span 2' }, [
E('div', { 'class': 'pb-card-header' }, [
E('div', { 'class': 'pb-card-title' }, [
E('span', {}, '📜'),
_('Logs')
])
]),
E('div', { 'class': 'pb-card-body' }, [
logs.length > 0 ?
E('div', { 'class': 'pb-logs', 'id': 'pb-logs' },
logs.map(function(line) {
return E('div', { 'class': 'pb-logs-line' }, line);
})
) :
E('div', { 'class': 'pb-empty' }, [
E('div', { 'class': 'pb-empty-icon' }, '📭'),
E('div', {}, _('No logs available'))
])
])
]);
},
updateDynamicContent: function() {
var status = this.statusData;
// Update status badge
var badge = document.getElementById('pb-status-badge');
if (badge) {
var statusClass = !status.installed ? 'not-installed' : (status.running ? 'running' : 'stopped');
var statusText = !status.installed ? _('Not Installed') : (status.running ? _('Running') : _('Stopped'));
badge.className = 'pb-status-badge ' + statusClass;
badge.innerHTML = '';
badge.appendChild(E('span', {}, statusClass === 'running' ? '●' : '○'));
badge.appendChild(document.createTextNode(' ' + statusText));
}
// Update stats
var statStatus = document.getElementById('stat-status');
if (statStatus) {
statStatus.textContent = status.running ? _('Online') : _('Offline');
}
// Update logs
var logsContainer = document.getElementById('pb-logs');
if (logsContainer && this.logsData.logs) {
logsContainer.innerHTML = '';
this.logsData.logs.forEach(function(line) {
logsContainer.appendChild(E('div', { 'class': 'pb-logs-line' }, line));
});
}
},
handleInstall: function() {
var self = this;
var btn = document.getElementById('btn-install');
if (btn) btn.disabled = true;
ui.showModal(_('Installing PicoBrew Server'), [
E('p', {}, _('This will download and install PicoBrew Server in an LXC container. This may take several minutes.')),
E('div', { 'class': 'pb-progress' }, [
E('div', { 'class': 'pb-progress-bar', 'id': 'modal-progress', 'style': 'width:0%' })
]),
E('p', { 'id': 'modal-status' }, _('Starting installation...'))
]);
callInstall().then(function(result) {
if (result && result.started) {
self.pollInstallProgress();
} else {
ui.hideModal();
ui.addNotification(null, E('p', {}, result.message || _('Installation failed')), 'error');
}
}).catch(function(err) {
ui.hideModal();
ui.addNotification(null, E('p', {}, _('Installation failed: ') + err.message), 'error');
});
},
pollInstallProgress: function() {
var self = this;
var checkProgress = function() {
callGetInstallProgress().then(function(result) {
var progressBar = document.getElementById('modal-progress');
var statusText = document.getElementById('modal-status');
if (progressBar) {
progressBar.style.width = (result.progress || 0) + '%';
}
if (statusText) {
statusText.textContent = result.message || '';
}
if (result.status === 'completed') {
ui.hideModal();
ui.addNotification(null, E('p', {}, _('PicoBrew Server installed successfully!')), 'success');
self.refreshData();
} else if (result.status === 'error') {
ui.hideModal();
ui.addNotification(null, E('p', {}, _('Installation failed: ') + result.message), 'error');
} else if (result.status === 'running') {
setTimeout(checkProgress, 3000);
} else {
setTimeout(checkProgress, 3000);
}
}).catch(function() {
setTimeout(checkProgress, 5000);
});
};
setTimeout(checkProgress, 2000);
},
handleStart: function() {
var self = this;
callStart().then(function(result) {
if (result && result.success) {
ui.addNotification(null, E('p', {}, _('PicoBrew Server started')), 'success');
} else {
ui.addNotification(null, E('p', {}, result.message || _('Failed to start')), 'error');
}
self.refreshData();
});
},
handleStop: function() {
var self = this;
callStop().then(function(result) {
if (result && result.success) {
ui.addNotification(null, E('p', {}, _('PicoBrew Server stopped')), 'info');
} else {
ui.addNotification(null, E('p', {}, result.message || _('Failed to stop')), 'error');
}
self.refreshData();
});
},
handleRestart: function() {
var self = this;
callRestart().then(function(result) {
if (result && result.success) {
ui.addNotification(null, E('p', {}, _('PicoBrew Server restarted')), 'success');
} else {
ui.addNotification(null, E('p', {}, result.message || _('Failed to restart')), 'error');
}
self.refreshData();
});
},
handleUpdate: function() {
var self = this;
ui.showModal(_('Updating PicoBrew Server'), [
E('p', {}, _('Updating PicoBrew Server to the latest version...')),
E('div', { 'class': 'spinner' })
]);
callUpdate().then(function(result) {
ui.hideModal();
if (result && result.started) {
ui.addNotification(null, E('p', {}, _('Update started. The server will restart automatically.')), 'info');
} else {
ui.addNotification(null, E('p', {}, result.message || _('Update failed')), 'error');
}
self.refreshData();
});
},
handleUninstall: function() {
var self = this;
ui.showModal(_('Confirm Uninstall'), [
E('p', {}, _('Are you sure you want to uninstall PicoBrew Server? Your data will be preserved.')),
E('div', { 'class': 'right' }, [
E('button', {
'class': 'btn',
'click': ui.hideModal
}, _('Cancel')),
E('button', {
'class': 'btn cbi-button-negative',
'click': function() {
ui.hideModal();
callUninstall().then(function(result) {
if (result && result.success) {
ui.addNotification(null, E('p', {}, _('PicoBrew Server uninstalled')), 'info');
} else {
ui.addNotification(null, E('p', {}, result.message || _('Uninstall failed')), 'error');
}
self.refreshData();
});
}
}, _('Uninstall'))
])
]);
}
});

View File

@ -0,0 +1,133 @@
'use strict';
'require view';
'require form';
'require rpc';
'require ui';
var callRestart = rpc.declare({
object: 'luci.picobrew',
method: 'restart',
expect: { result: {} }
});
return view.extend({
render: function() {
var m, s, o;
m = new form.Map('picobrew', _('PicoBrew Settings'),
_('Configure PicoBrew Server settings. Changes require a service restart to take effect.'));
// Main settings section
s = m.section(form.TypedSection, 'picobrew', _('Server Settings'));
s.anonymous = true;
s.addremove = false;
o = s.option(form.Flag, 'enabled', _('Enable Service'),
_('Enable or disable the PicoBrew Server service'));
o.default = '0';
o.rmempty = false;
o = s.option(form.Value, 'http_port', _('HTTP Port'),
_('Port for the PicoBrew web interface'));
o.datatype = 'port';
o.default = '8080';
o.rmempty = false;
o = s.option(form.Value, 'http_host', _('Listen Address'),
_('IP address to listen on (0.0.0.0 for all interfaces)'));
o.datatype = 'ipaddr';
o.default = '0.0.0.0';
o.rmempty = false;
o = s.option(form.Value, 'data_path', _('Data Path'),
_('Path for storing recipes, sessions, and logs'));
o.default = '/srv/picobrew';
o.rmempty = false;
o = s.option(form.ListValue, 'memory_limit', _('Memory Limit'),
_('Maximum memory for the container'));
o.value('256M', '256 MB');
o.value('512M', '512 MB');
o.value('768M', '768 MB');
o.value('1G', '1 GB');
o.default = '512M';
o = s.option(form.ListValue, 'log_level', _('Log Level'),
_('Logging verbosity'));
o.value('DEBUG', 'Debug');
o.value('INFO', 'Info');
o.value('WARNING', 'Warning');
o.value('ERROR', 'Error');
o.default = 'INFO';
// Server section (HTTPS)
s = m.section(form.TypedSection, 'server', _('HTTPS Settings'));
s.anonymous = true;
s.addremove = false;
o = s.option(form.Value, 'dns_name', _('DNS Name'),
_('Domain name for accessing the server (optional)'));
o.placeholder = 'picobrew.local';
o.rmempty = true;
o = s.option(form.Flag, 'https_enabled', _('Enable HTTPS'),
_('Enable HTTPS (requires certificates)'));
o.default = '0';
o = s.option(form.Value, 'cert_path', _('Certificate Path'),
_('Path to SSL certificate file'));
o.depends('https_enabled', '1');
o.rmempty = true;
o = s.option(form.Value, 'key_path', _('Key Path'),
_('Path to SSL private key file'));
o.depends('https_enabled', '1');
o.rmempty = true;
// Brewing defaults section
s = m.section(form.TypedSection, 'brewing', _('Brewing Defaults'));
s.anonymous = true;
s.addremove = false;
o = s.option(form.ListValue, 'units', _('Units'),
_('Temperature and measurement units'));
o.value('metric', 'Metric (°C, L, kg)');
o.value('imperial', 'Imperial (°F, gal, lb)');
o.default = 'metric';
o = s.option(form.Value, 'default_boil_temp', _('Default Boil Temperature'),
_('Default boiling temperature'));
o.datatype = 'uinteger';
o.default = '100';
o.rmempty = false;
o = s.option(form.Value, 'default_mash_temp', _('Default Mash Temperature'),
_('Default mashing temperature'));
o.datatype = 'uinteger';
o.default = '67';
o.rmempty = false;
return m.render().then(function(mapEl) {
// Add restart button after the form
var restartBtn = E('button', {
'class': 'cbi-button cbi-button-apply',
'style': 'margin-top: 1em;',
'click': function() {
ui.showModal(_('Restarting Service'), [
E('p', { 'class': 'spinning' }, _('Restarting PicoBrew Server...'))
]);
callRestart().then(function() {
ui.hideModal();
ui.addNotification(null, E('p', {}, _('Service restarted successfully')), 'success');
}).catch(function(err) {
ui.hideModal();
ui.addNotification(null, E('p', {}, _('Failed to restart: ') + err.message), 'error');
});
}
}, _('Restart Service'));
var wrapper = E('div', {}, [mapEl, restartBtn]);
return wrapper;
});
}
});

View File

@ -0,0 +1,462 @@
#!/bin/sh
# SPDX-License-Identifier: Apache-2.0
# LuCI RPC backend for PicoBrew Server
# Copyright (C) 2025 CyberMind.fr
. /lib/functions.sh
. /usr/share/libubox/jshn.sh
CONFIG="picobrew"
LXC_NAME="picobrew"
LXC_PATH="/srv/lxc"
REPO_PATH="/srv/picobrew/app"
# JSON helpers
json_init_obj() { json_init; json_add_object "result"; }
json_close_obj() { json_close_object; json_dump; }
json_error() {
json_init
json_add_object "error"
json_add_string "message" "$1"
json_close_object
json_dump
}
json_success() {
json_init_obj
json_add_boolean "success" 1
[ -n "$1" ] && json_add_string "message" "$1"
json_close_obj
}
# Check if container is running
lxc_running() {
lxc-info -n "$LXC_NAME" -s 2>/dev/null | grep -q "RUNNING"
}
# Check if container exists
lxc_exists() {
[ -f "$LXC_PATH/$LXC_NAME/config" ] && [ -d "$LXC_PATH/$LXC_NAME/rootfs" ]
}
# Get service status
get_status() {
local enabled running installed uptime
local http_port data_path memory_limit
config_load "$CONFIG"
config_get enabled main enabled "0"
config_get http_port main http_port "8080"
config_get data_path main data_path "/srv/picobrew"
config_get memory_limit main memory_limit "512M"
running="false"
installed="false"
uptime=""
if lxc_exists; then
installed="true"
fi
if lxc_running; then
running="true"
uptime=$(lxc-info -n "$LXC_NAME" 2>/dev/null | grep -i "cpu use" | head -1 | awk '{print $3}')
fi
# Check if repo exists
local repo_installed="false"
[ -d "$REPO_PATH/.git" ] && repo_installed="true"
# Get LAN IP for URL
local lan_ip
lan_ip=$(uci -q get network.lan.ipaddr || echo "192.168.1.1")
json_init_obj
json_add_boolean "enabled" "$( [ "$enabled" = "1" ] && echo 1 || echo 0 )"
json_add_boolean "running" "$( [ "$running" = "true" ] && echo 1 || echo 0 )"
json_add_boolean "installed" "$( [ "$installed" = "true" ] && echo 1 || echo 0 )"
json_add_boolean "repo_installed" "$( [ "$repo_installed" = "true" ] && echo 1 || echo 0 )"
json_add_string "uptime" "$uptime"
json_add_int "http_port" "$http_port"
json_add_string "data_path" "$data_path"
json_add_string "memory_limit" "$memory_limit"
json_add_string "web_url" "http://${lan_ip}:${http_port}"
json_add_string "container_name" "$LXC_NAME"
json_close_obj
}
# Get configuration
get_config() {
local http_port http_host data_path memory_limit log_level
local dns_name https_enabled cert_path key_path
local default_boil_temp default_mash_temp units
config_load "$CONFIG"
# Main settings
config_get http_port main http_port "8080"
config_get http_host main http_host "0.0.0.0"
config_get data_path main data_path "/srv/picobrew"
config_get memory_limit main memory_limit "512M"
config_get log_level main log_level "INFO"
config_get enabled main enabled "0"
# Server settings
config_get dns_name server dns_name ""
config_get https_enabled server https_enabled "0"
config_get cert_path server cert_path ""
config_get key_path server key_path ""
# Brewing settings
config_get default_boil_temp brewing default_boil_temp "100"
config_get default_mash_temp brewing default_mash_temp "67"
config_get units brewing units "metric"
json_init_obj
json_add_object "main"
json_add_boolean "enabled" "$( [ "$enabled" = "1" ] && echo 1 || echo 0 )"
json_add_int "http_port" "$http_port"
json_add_string "http_host" "$http_host"
json_add_string "data_path" "$data_path"
json_add_string "memory_limit" "$memory_limit"
json_add_string "log_level" "$log_level"
json_close_object
json_add_object "server"
json_add_string "dns_name" "$dns_name"
json_add_boolean "https_enabled" "$( [ "$https_enabled" = "1" ] && echo 1 || echo 0 )"
json_add_string "cert_path" "$cert_path"
json_add_string "key_path" "$key_path"
json_close_object
json_add_object "brewing"
json_add_int "default_boil_temp" "$default_boil_temp"
json_add_int "default_mash_temp" "$default_mash_temp"
json_add_string "units" "$units"
json_close_object
json_close_obj
}
# Set configuration value
set_config() {
local section="$1"
local option="$2"
local value="$3"
if [ -z "$section" ] || [ -z "$option" ]; then
json_error "Missing section or option"
return
fi
uci set "${CONFIG}.${section}.${option}=${value}"
uci commit "$CONFIG"
json_success "Configuration updated"
}
# Start service
start_service() {
if lxc_running; then
json_error "Service is already running"
return
fi
if ! lxc_exists; then
json_error "Container not installed. Run install first."
return
fi
/etc/init.d/picobrew start >/dev/null 2>&1 &
sleep 2
if lxc_running; then
json_success "Service started"
else
json_error "Failed to start service"
fi
}
# Stop service
stop_service() {
if ! lxc_running; then
json_error "Service is not running"
return
fi
/etc/init.d/picobrew stop >/dev/null 2>&1
sleep 2
if ! lxc_running; then
json_success "Service stopped"
else
json_error "Failed to stop service"
fi
}
# Restart service
restart_service() {
/etc/init.d/picobrew restart >/dev/null 2>&1 &
sleep 3
if lxc_running; then
json_success "Service restarted"
else
json_error "Service restart failed"
fi
}
# Install PicoBrew
install() {
if lxc_exists; then
json_error "Already installed. Use update to refresh."
return
fi
# Run install in background
/usr/sbin/picobrewctl install >/var/log/picobrew-install.log 2>&1 &
json_init_obj
json_add_boolean "started" 1
json_add_string "message" "Installation started in background"
json_add_string "log_file" "/var/log/picobrew-install.log"
json_close_obj
}
# Uninstall PicoBrew
uninstall() {
/usr/sbin/picobrewctl uninstall >/dev/null 2>&1
if ! lxc_exists; then
json_success "Uninstalled successfully"
else
json_error "Uninstall failed"
fi
}
# Update PicoBrew
update() {
if ! [ -d "$REPO_PATH/.git" ]; then
json_error "Not installed. Run install first."
return
fi
# Run update in background
/usr/sbin/picobrewctl update >/var/log/picobrew-update.log 2>&1 &
json_init_obj
json_add_boolean "started" 1
json_add_string "message" "Update started in background"
json_add_string "log_file" "/var/log/picobrew-update.log"
json_close_obj
}
# Get logs
get_logs() {
local lines="${1:-100}"
local data_path
config_load "$CONFIG"
config_get data_path main data_path "/srv/picobrew"
json_init_obj
json_add_array "logs"
# Get container logs from data path
if [ -d "$data_path/logs" ]; then
local logfile
for logfile in "$data_path/logs"/*.log; do
[ -f "$logfile" ] || continue
tail -n "$lines" "$logfile" 2>/dev/null | while IFS= read -r line; do
json_add_string "" "$line"
done
done
fi
# Also check install/update logs
for logfile in /var/log/picobrew-install.log /var/log/picobrew-update.log; do
[ -f "$logfile" ] || continue
tail -n 50 "$logfile" 2>/dev/null | while IFS= read -r line; do
json_add_string "" "$line"
done
done
json_close_array
json_close_obj
}
# Get brewing sessions (placeholder - depends on picobrew-server API)
get_sessions() {
local data_path
config_load "$CONFIG"
config_get data_path main data_path "/srv/picobrew"
json_init_obj
json_add_array "sessions"
# List session files if they exist
if [ -d "$data_path/sessions" ]; then
for session in "$data_path/sessions"/*.json; do
[ -f "$session" ] || continue
local name=$(basename "$session" .json)
json_add_object ""
json_add_string "id" "$name"
json_add_string "file" "$session"
json_close_object
done
fi
json_close_array
json_close_obj
}
# Get recipes (placeholder - depends on picobrew-server API)
get_recipes() {
local data_path
config_load "$CONFIG"
config_get data_path main data_path "/srv/picobrew"
json_init_obj
json_add_array "recipes"
# List recipe files if they exist
if [ -d "$data_path/recipes" ]; then
for recipe in "$data_path/recipes"/*.json; do
[ -f "$recipe" ] || continue
local name=$(basename "$recipe" .json)
json_add_object ""
json_add_string "id" "$name"
json_add_string "file" "$recipe"
json_close_object
done
fi
json_close_array
json_close_obj
}
# Check install progress
get_install_progress() {
local log_file="/var/log/picobrew-install.log"
local status="unknown"
local progress=0
local message=""
if [ -f "$log_file" ]; then
# Check for completion markers
if grep -q "Installation complete" "$log_file" 2>/dev/null; then
status="completed"
progress=100
message="Installation completed successfully"
elif grep -q "ERROR" "$log_file" 2>/dev/null; then
status="error"
message=$(grep "ERROR" "$log_file" | tail -1)
else
status="running"
# Estimate progress based on log content
if grep -q "Extracting rootfs" "$log_file" 2>/dev/null; then
progress=60
message="Extracting container rootfs..."
elif grep -q "Downloading Alpine" "$log_file" 2>/dev/null; then
progress=40
message="Downloading Alpine rootfs..."
elif grep -q "Cloning" "$log_file" 2>/dev/null; then
progress=20
message="Cloning PicoBrew repository..."
else
progress=10
message="Starting installation..."
fi
fi
else
status="not_started"
message="Installation has not been started"
fi
# Check if process is still running
if pgrep -f "picobrewctl install" >/dev/null 2>&1; then
status="running"
fi
json_init_obj
json_add_string "status" "$status"
json_add_int "progress" "$progress"
json_add_string "message" "$message"
json_close_obj
}
# Main RPC handler
case "$1" in
list)
cat <<-EOF
{
"get_status": {},
"get_config": {},
"set_config": {"section": "str", "option": "str", "value": "str"},
"start": {},
"stop": {},
"restart": {},
"install": {},
"uninstall": {},
"update": {},
"get_logs": {"lines": 100},
"get_sessions": {},
"get_recipes": {},
"get_install_progress": {}
}
EOF
;;
call)
case "$2" in
get_status)
get_status
;;
get_config)
get_config
;;
set_config)
read -r input
section=$(echo "$input" | jsonfilter -e '@.section' 2>/dev/null)
option=$(echo "$input" | jsonfilter -e '@.option' 2>/dev/null)
value=$(echo "$input" | jsonfilter -e '@.value' 2>/dev/null)
set_config "$section" "$option" "$value"
;;
start)
start_service
;;
stop)
stop_service
;;
restart)
restart_service
;;
install)
install
;;
uninstall)
uninstall
;;
update)
update
;;
get_logs)
read -r input
lines=$(echo "$input" | jsonfilter -e '@.lines' 2>/dev/null)
get_logs "${lines:-100}"
;;
get_sessions)
get_sessions
;;
get_recipes)
get_recipes
;;
get_install_progress)
get_install_progress
;;
*)
json_error "Unknown method: $2"
;;
esac
;;
esac

View File

@ -0,0 +1,29 @@
{
"admin/services/picobrew": {
"title": "PicoBrew",
"order": 85,
"action": {
"type": "firstchild"
},
"depends": {
"acl": ["luci-app-picobrew"],
"uci": {"picobrew": true}
}
},
"admin/services/picobrew/dashboard": {
"title": "Dashboard",
"order": 10,
"action": {
"type": "view",
"path": "picobrew/dashboard"
}
},
"admin/services/picobrew/settings": {
"title": "Settings",
"order": 20,
"action": {
"type": "view",
"path": "picobrew/settings"
}
}
}

View File

@ -0,0 +1,17 @@
{
"luci-app-picobrew": {
"description": "Grant access to PicoBrew Server management",
"read": {
"ubus": {
"luci.picobrew": ["get_status", "get_config", "get_logs", "get_sessions", "get_recipes", "get_install_progress"]
},
"uci": ["picobrew"]
},
"write": {
"ubus": {
"luci.picobrew": ["set_config", "start", "stop", "restart", "install", "uninstall", "update"]
},
"uci": ["picobrew"]
}
}
}

View File

@ -0,0 +1,78 @@
# SPDX-License-Identifier: MIT
#
# Copyright (C) 2025 CyberMind.fr
#
# SecuBox PicoBrew Server - Homebrew brewing controller
include $(TOPDIR)/rules.mk
PKG_NAME:=secubox-app-picobrew
PKG_VERSION:=1.0.0
PKG_RELEASE:=1
PKG_ARCH:=all
PKG_MAINTAINER:=CyberMind Studio <contact@cybermind.fr>
PKG_LICENSE:=MIT
include $(INCLUDE_DIR)/package.mk
define Package/secubox-app-picobrew
SECTION:=utils
CATEGORY:=Utilities
PKGARCH:=all
SUBMENU:=SecuBox Apps
TITLE:=SecuBox PicoBrew Server
DEPENDS:=+uci +libuci +jsonfilter +wget-ssl +tar +lxc +lxc-common +git
endef
define Package/secubox-app-picobrew/description
PicoBrew Server - Self-hosted brewing controller for PicoBrew devices
Features:
- Control PicoBrew Zymatic, Z, Pico C, and Pico Pro devices
- Recipe management and import from BeerSmith
- Real-time brewing session monitoring
- Temperature and step control
- Session history and logging
Runs in LXC container with Python/Flask backend.
Configure in /etc/config/picobrew.
endef
define Package/secubox-app-picobrew/conffiles
/etc/config/picobrew
endef
define Build/Compile
endef
define Package/secubox-app-picobrew/install
$(INSTALL_DIR) $(1)/etc/config
$(INSTALL_CONF) ./files/etc/config/picobrew $(1)/etc/config/picobrew
$(INSTALL_DIR) $(1)/etc/init.d
$(INSTALL_BIN) ./files/etc/init.d/picobrew $(1)/etc/init.d/picobrew
$(INSTALL_DIR) $(1)/usr/sbin
$(INSTALL_BIN) ./files/usr/sbin/picobrewctl $(1)/usr/sbin/picobrewctl
endef
define Package/secubox-app-picobrew/postinst
#!/bin/sh
[ -n "$${IPKG_INSTROOT}" ] || {
echo ""
echo "PicoBrew Server installed."
echo ""
echo "To install and start PicoBrew Server:"
echo " picobrewctl install"
echo " /etc/init.d/picobrew start"
echo ""
echo "Web interface: http://<router-ip>:8080"
echo ""
echo "Configure your PicoBrew device to connect to this server."
echo ""
}
exit 0
endef
$(eval $(call BuildPackage,secubox-app-picobrew))

View File

@ -0,0 +1,18 @@
config picobrew 'main'
option enabled '0'
option http_port '8080'
option http_host '0.0.0.0'
option data_path '/srv/picobrew'
option memory_limit '512M'
option log_level 'INFO'
config server 'server'
option dns_name ''
option https_enabled '0'
option cert_path ''
option key_path ''
config brewing 'brewing'
option default_boil_temp '100'
option default_mash_temp '67'
option units 'metric'

View File

@ -0,0 +1,45 @@
#!/bin/sh /etc/rc.common
# SecuBox PicoBrew Server - Homebrew brewing controller
# Copyright (C) 2025 CyberMind.fr
START=95
STOP=10
USE_PROCD=1
PROG=/usr/sbin/picobrewctl
CONFIG=picobrew
start_service() {
local enabled
config_load "$CONFIG"
config_get enabled main enabled '0'
[ "$enabled" = "1" ] || {
echo "PicoBrew is disabled. Enable with: uci set picobrew.main.enabled=1"
return 0
}
procd_open_instance
procd_set_param command "$PROG" service-run
procd_set_param respawn 3600 5 5
procd_set_param stdout 1
procd_set_param stderr 1
procd_close_instance
}
stop_service() {
"$PROG" service-stop
}
service_triggers() {
procd_add_reload_trigger "$CONFIG"
}
reload_service() {
stop
start
}
status() {
"$PROG" status
}

View File

@ -0,0 +1,427 @@
#!/bin/sh
# SecuBox PicoBrew Server Controller
# Copyright (C) 2025 CyberMind.fr
#
# Manages PicoBrew Server in LXC container
CONFIG="picobrew"
LXC_NAME="picobrew"
# Paths
LXC_PATH="/srv/lxc"
LXC_ROOTFS="$LXC_PATH/$LXC_NAME/rootfs"
LXC_CONFIG="$LXC_PATH/$LXC_NAME/config"
REPO_URL="https://github.com/CyberMind-FR/picobrew-server.git"
REPO_PATH="/srv/picobrew/app"
# Logging
log_info() { echo "[INFO] $*"; logger -t picobrew "$*"; }
log_error() { echo "[ERROR] $*" >&2; logger -t picobrew -p err "$*"; }
log_debug() { [ "$DEBUG" = "1" ] && echo "[DEBUG] $*"; }
# Helpers
require_root() {
[ "$(id -u)" -eq 0 ] || {
log_error "This command requires root privileges"
exit 1
}
}
has_lxc() { command -v lxc-start >/dev/null 2>&1; }
has_git() { command -v git >/dev/null 2>&1; }
ensure_dir() { [ -d "$1" ] || mkdir -p "$1"; }
uci_get() { uci -q get ${CONFIG}.$1; }
uci_set() { uci set ${CONFIG}.$1="$2" && uci commit ${CONFIG}; }
# Load configuration
load_config() {
http_port="$(uci_get main.http_port)" || http_port="8080"
http_host="$(uci_get main.http_host)" || http_host="0.0.0.0"
data_path="$(uci_get main.data_path)" || data_path="/srv/picobrew"
memory_limit="$(uci_get main.memory_limit)" || memory_limit="512M"
log_level="$(uci_get main.log_level)" || log_level="INFO"
dns_name="$(uci_get server.dns_name)" || dns_name=""
https_enabled="$(uci_get server.https_enabled)" || https_enabled="0"
units="$(uci_get brewing.units)" || units="metric"
ensure_dir "$data_path"
ensure_dir "$data_path/recipes"
ensure_dir "$data_path/sessions"
ensure_dir "$data_path/logs"
}
# Usage
usage() {
cat <<EOF
SecuBox PicoBrew Server Controller
Usage: $(basename $0) <command> [options]
Commands:
install Download and install PicoBrew Server
uninstall Remove PicoBrew Server container
update Update PicoBrew Server to latest version
status Show service status
logs Show container logs
shell Open shell in container
service-run Start service (used by init)
service-stop Stop service (used by init)
Configuration:
/etc/config/picobrew
Data directory:
/srv/picobrew
EOF
}
# Check prerequisites
lxc_check_prereqs() {
if ! has_lxc; then
log_error "LXC not installed. Install with: opkg install lxc lxc-common"
return 1
fi
if ! has_git; then
log_error "Git not installed. Install with: opkg install git"
return 1
fi
return 0
}
# Clone or update repo
repo_clone() {
load_config
if [ -d "$REPO_PATH/.git" ]; then
log_info "Updating PicoBrew Server repository..."
cd "$REPO_PATH" && git pull
else
log_info "Cloning PicoBrew Server repository..."
ensure_dir "$(dirname "$REPO_PATH")"
git clone "$REPO_URL" "$REPO_PATH"
fi
}
# Create Python LXC rootfs from Alpine
lxc_create_rootfs() {
local rootfs="$LXC_ROOTFS"
local arch=$(uname -m)
log_info "Creating Alpine rootfs for PicoBrew..."
ensure_dir "$rootfs"
# Use Alpine mini rootfs
local alpine_version="3.19"
case "$arch" in
x86_64) alpine_arch="x86_64" ;;
aarch64) alpine_arch="aarch64" ;;
armv7l) alpine_arch="armv7" ;;
*) log_error "Unsupported architecture: $arch"; return 1 ;;
esac
local alpine_url="https://dl-cdn.alpinelinux.org/alpine/v${alpine_version}/releases/${alpine_arch}/alpine-minirootfs-${alpine_version}.0-${alpine_arch}.tar.gz"
local tmpfile="/tmp/alpine-rootfs.tar.gz"
log_info "Downloading Alpine rootfs..."
wget -q -O "$tmpfile" "$alpine_url" || {
log_error "Failed to download Alpine rootfs"
return 1
}
log_info "Extracting rootfs..."
tar -xzf "$tmpfile" -C "$rootfs" || {
log_error "Failed to extract rootfs"
return 1
}
rm -f "$tmpfile"
# Setup resolv.conf
cp /etc/resolv.conf "$rootfs/etc/resolv.conf" 2>/dev/null || \
echo "nameserver 1.1.1.1" > "$rootfs/etc/resolv.conf"
# Create startup script
cat > "$rootfs/opt/start-picobrew.sh" << 'STARTUP'
#!/bin/sh
set -e
# Install Python and dependencies on first run
if [ ! -f /opt/.installed ]; then
echo "Installing Python and dependencies..."
apk update
apk add --no-cache python3 py3-pip py3-flask py3-requests py3-pyyaml git
# Install PicoBrew dependencies
if [ -d /app ]; then
cd /app
pip3 install --break-system-packages -r requirements.txt 2>/dev/null || \
pip3 install -r requirements.txt 2>/dev/null || true
fi
touch /opt/.installed
fi
# Start PicoBrew Server
cd /app
export FLASK_APP=app.py
export FLASK_ENV=production
export PICOBREW_HOST="${PICOBREW_HOST:-0.0.0.0}"
export PICOBREW_PORT="${PICOBREW_PORT:-8080}"
export PICOBREW_LOG_LEVEL="${PICOBREW_LOG_LEVEL:-INFO}"
echo "Starting PicoBrew Server on ${PICOBREW_HOST}:${PICOBREW_PORT}..."
exec python3 -m flask run --host="$PICOBREW_HOST" --port="$PICOBREW_PORT"
STARTUP
chmod +x "$rootfs/opt/start-picobrew.sh"
log_info "Rootfs created successfully"
return 0
}
# Create LXC config
lxc_create_config() {
load_config
ensure_dir "$(dirname "$LXC_CONFIG")"
# Convert memory limit to bytes
local mem_bytes
case "$memory_limit" in
*G|*g) mem_bytes=$((${memory_limit%[Gg]} * 1024 * 1024 * 1024)) ;;
*M|*m) mem_bytes=$((${memory_limit%[Mm]} * 1024 * 1024)) ;;
*K|*k) mem_bytes=$((${memory_limit%[Kk]} * 1024)) ;;
*) mem_bytes="$memory_limit" ;;
esac
cat > "$LXC_CONFIG" << EOF
# PicoBrew Server LXC Configuration
lxc.uts.name = $LXC_NAME
lxc.rootfs.path = dir:$LXC_ROOTFS
lxc.arch = $(uname -m)
# Network: use host network for device discovery
lxc.net.0.type = none
# Mount points
lxc.mount.auto = proc:mixed sys:ro cgroup:mixed
lxc.mount.entry = $REPO_PATH app none bind,create=dir 0 0
lxc.mount.entry = $data_path/recipes app/recipes none bind,create=dir 0 0
lxc.mount.entry = $data_path/sessions app/sessions none bind,create=dir 0 0
lxc.mount.entry = $data_path/logs logs none bind,create=dir 0 0
# Environment
lxc.environment = PICOBREW_HOST=$http_host
lxc.environment = PICOBREW_PORT=$http_port
lxc.environment = PICOBREW_LOG_LEVEL=$log_level
lxc.environment = PICOBREW_UNITS=$units
# Security
lxc.cap.drop = sys_admin sys_module mac_admin mac_override sys_time sys_rawio
# Resource limits
lxc.cgroup.memory.limit_in_bytes = $mem_bytes
# Init command
lxc.init.cmd = /opt/start-picobrew.sh
EOF
log_info "LXC config created"
}
# Container control
lxc_running() {
lxc-info -n "$LXC_NAME" -s 2>/dev/null | grep -q "RUNNING"
}
lxc_exists() {
[ -f "$LXC_CONFIG" ] && [ -d "$LXC_ROOTFS" ]
}
lxc_stop() {
if lxc_running; then
log_info "Stopping PicoBrew container..."
lxc-stop -n "$LXC_NAME" -k 2>/dev/null || true
sleep 2
fi
}
lxc_run() {
load_config
lxc_stop
if ! lxc_exists; then
log_error "Container not installed. Run: picobrewctl install"
return 1
fi
# Regenerate config in case settings changed
lxc_create_config
log_info "Starting PicoBrew container..."
exec lxc-start -n "$LXC_NAME" -F -f "$LXC_CONFIG"
}
# Commands
cmd_install() {
require_root
load_config
log_info "Installing PicoBrew Server..."
lxc_check_prereqs || exit 1
# Clone repository
repo_clone || exit 1
# Create container
if ! lxc_exists; then
lxc_create_rootfs || exit 1
fi
lxc_create_config || exit 1
# Enable service
uci_set main.enabled '1'
/etc/init.d/picobrew enable 2>/dev/null || true
log_info "Installation complete!"
log_info ""
log_info "Start with: /etc/init.d/picobrew start"
log_info "Web interface: http://<router-ip>:$http_port"
}
cmd_uninstall() {
require_root
log_info "Uninstalling PicoBrew Server..."
# Stop service
/etc/init.d/picobrew stop 2>/dev/null || true
/etc/init.d/picobrew disable 2>/dev/null || true
lxc_stop
# Remove container (keep data)
if [ -d "$LXC_PATH/$LXC_NAME" ]; then
rm -rf "$LXC_PATH/$LXC_NAME"
log_info "Container removed"
fi
uci_set main.enabled '0'
log_info "PicoBrew Server uninstalled"
log_info "Data preserved in: $(uci_get main.data_path)"
}
cmd_update() {
require_root
load_config
log_info "Updating PicoBrew Server..."
# Update repo
repo_clone || exit 1
# Recreate container to get fresh dependencies
lxc_stop
if [ -d "$LXC_ROOTFS" ]; then
rm -rf "$LXC_ROOTFS"
fi
lxc_create_rootfs || exit 1
# Restart if was running
if [ "$(uci_get main.enabled)" = "1" ]; then
/etc/init.d/picobrew restart
fi
log_info "Update complete!"
}
cmd_status() {
load_config
local enabled="$(uci_get main.enabled)"
local running="false"
local uptime=""
if lxc_running; then
running="true"
uptime=$(lxc-info -n "$LXC_NAME" 2>/dev/null | grep -i "cpu use" | head -1)
fi
cat << EOF
PicoBrew Server Status
=====================
Enabled: $([ "$enabled" = "1" ] && echo "yes" || echo "no")
Running: $([ "$running" = "true" ] && echo "yes" || echo "no")
HTTP Port: $http_port
Data Path: $data_path
Memory: $memory_limit
Container: $LXC_NAME
Rootfs: $LXC_ROOTFS
Config: $LXC_CONFIG
EOF
if [ "$running" = "true" ]; then
echo "Web interface: http://$(uci -q get network.lan.ipaddr || echo "localhost"):$http_port"
fi
}
cmd_logs() {
load_config
if [ -d "$data_path/logs" ]; then
if [ -n "$(ls -A "$data_path/logs" 2>/dev/null)" ]; then
tail -f "$data_path/logs"/*.log 2>/dev/null || \
cat "$data_path/logs"/*.log 2>/dev/null || \
echo "No logs found"
else
echo "No logs yet"
fi
else
echo "Log directory not found"
fi
}
cmd_shell() {
require_root
if ! lxc_running; then
log_error "Container not running"
exit 1
fi
lxc-attach -n "$LXC_NAME" -- /bin/sh
}
cmd_service_run() {
require_root
load_config
lxc_check_prereqs || exit 1
lxc_run
}
cmd_service_stop() {
require_root
lxc_stop
}
# Main
case "${1:-}" in
install) shift; cmd_install "$@" ;;
uninstall) shift; cmd_uninstall "$@" ;;
update) shift; cmd_update "$@" ;;
status) shift; cmd_status "$@" ;;
logs) shift; cmd_logs "$@" ;;
shell) shift; cmd_shell "$@" ;;
service-run) shift; cmd_service_run "$@" ;;
service-stop) shift; cmd_service_stop "$@" ;;
*) usage ;;
esac