feat: Unterstützung für internen Speicher und SMB-Freigabe hinzugefügt; Versionsnummer auf 1.0.51 erhöht

This commit is contained in:
2026-05-09 14:17:09 +02:00
parent 2ce8e95623
commit d05d0938ff
2 changed files with 303 additions and 15 deletions

314
app.py
View File

@@ -38,6 +38,7 @@ CONFIG_FILE = BASE_DIR / 'config.json'
STATE_FILE = BASE_DIR / 'state.json'
LOG_DIR = BASE_DIR / 'logs'
LOG_FILE = LOG_DIR / 'picopy.log'
INTERNAL_DEST_DIR = BASE_DIR / 'internal'
LOG_DIR.mkdir(parents=True, exist_ok=True)
logging.basicConfig(
@@ -56,6 +57,8 @@ DEFAULT_CONFIG = {
'source_ports': [], # [{port, label}, ...]
'source_port': None, 'source_label': '', # Migration legacy
'dest_port': None, 'dest_label': '',
'dest_type': 'usb', 'internal_dest_label': 'Interner Speicher',
'internal_share_enabled': False,
'folder_format': '%Y-%m-%d', 'add_time': True,
'subfolder': True, 'auto_copy': True,
'file_filter': '', 'exclude_system': True,
@@ -408,6 +411,163 @@ def wg_save_config(content: str):
return False, str(e)
# -- Interner Speicher / SMB-Freigabe -----------------------------------------
SAMBA_CONF = Path('/etc/samba/smb.conf')
SAMBA_BEGIN = '# BEGIN PICOPY INTERNAL SHARE'
SAMBA_END = '# END PICOPY INTERNAL SHARE'
internal_share_state = {
'installed': False,
'enabled': False,
'active': False,
'path': str(INTERNAL_DEST_DIR),
'share': 'PiCopy',
'pkg_running': False,
'pkg_error': None,
'error': None,
}
internal_share_lock = threading.Lock()
def _internal_usage():
INTERNAL_DEST_DIR.mkdir(parents=True, exist_ok=True)
usage = shutil.disk_usage(INTERNAL_DEST_DIR)
return {
'path': str(INTERNAL_DEST_DIR),
'total': usage.total,
'used': usage.used,
'free': usage.free,
}
def internal_dest_device(cfg=None):
cfg = cfg or load_cfg()
usage = _internal_usage()
return {
'device': 'internal',
'usb_port': '__internal__',
'mount': str(INTERNAL_DEST_DIR),
'label': cfg.get('internal_dest_label') or 'Interner Speicher',
'size': _fmt_bytes(usage['free']) + ' frei',
'internal': True,
}
def smbd_installed():
return shutil.which('smbd') is not None
def _systemctl(*args, timeout=20):
try:
return subprocess.run(['systemctl'] + list(args), capture_output=True,
text=True, timeout=timeout)
except Exception as e:
return subprocess.CompletedProcess(['systemctl'] + list(args), 1,
stdout='', stderr=str(e))
def _smbd_active():
if not smbd_installed():
return False
r = _systemctl('is-active', 'smbd', timeout=5)
return r.returncode == 0 and r.stdout.strip() == 'active'
def internal_share_update_state():
cfg = load_cfg()
usage = _internal_usage()
with internal_share_lock:
internal_share_state.update(
installed=smbd_installed(),
enabled=bool(cfg.get('internal_share_enabled')),
active=_smbd_active(),
path=usage['path'],
total=usage['total'],
used=usage['used'],
free=usage['free'],
)
return dict(internal_share_state)
def _write_samba_share(enabled: bool):
old = SAMBA_CONF.read_text(encoding='utf-8') if SAMBA_CONF.exists() else ''
pattern = re.compile(rf'\n?{re.escape(SAMBA_BEGIN)}.*?{re.escape(SAMBA_END)}\n?', re.S)
cleaned = pattern.sub('\n', old).rstrip() + '\n'
if enabled:
INTERNAL_DEST_DIR.mkdir(parents=True, exist_ok=True)
INTERNAL_DEST_DIR.chmod(0o755)
block = f"""
{SAMBA_BEGIN}
[PiCopy]
path = {INTERNAL_DEST_DIR}
browseable = yes
read only = yes
guest ok = yes
force user = root
{SAMBA_END}
"""
cleaned += block
tmp = SAMBA_CONF.with_suffix('.conf.picopy_tmp')
tmp.write_text(cleaned, encoding='utf-8')
os.replace(str(tmp), str(SAMBA_CONF))
def _install_samba_if_needed():
if smbd_installed():
return True, ''
with internal_share_lock:
internal_share_state.update(pkg_running=True, pkg_error=None)
try:
r = subprocess.run(['apt-get', 'install', '-y', 'samba'],
capture_output=True, text=True, timeout=300,
env={**os.environ, 'DEBIAN_FRONTEND': 'noninteractive'})
if r.returncode != 0:
err = (r.stderr.strip().splitlines()[-1]
if r.stderr.strip() else 'samba-Installation fehlgeschlagen')
with internal_share_lock:
internal_share_state['pkg_error'] = err
return False, err
return True, ''
except Exception as e:
with internal_share_lock:
internal_share_state['pkg_error'] = str(e)
return False, str(e)
finally:
with internal_share_lock:
internal_share_state['pkg_running'] = False
def set_internal_share_enabled(enabled: bool):
ok, err = (True, '')
if enabled:
ok, err = _install_samba_if_needed()
if not ok:
return False, err
elif not smbd_installed():
cfg = load_cfg()
cfg['internal_share_enabled'] = False
save_cfg(cfg)
internal_share_update_state()
return True, ''
try:
_write_samba_share(enabled)
if enabled:
_systemctl('enable', '--now', 'smbd', timeout=60)
_systemctl('restart', 'smbd', timeout=60)
else:
_systemctl('restart', 'smbd', timeout=60)
cfg = load_cfg()
cfg['internal_share_enabled'] = enabled
save_cfg(cfg)
internal_share_update_state()
return True, ''
except Exception as e:
with internal_share_lock:
internal_share_state['error'] = str(e)
return False, str(e)
def wg_monitor():
while True:
try:
@@ -485,6 +645,9 @@ def usb_devices():
return result
def ensure_mount(dev_info):
if dev_info.get('internal'):
INTERNAL_DEST_DIR.mkdir(parents=True, exist_ok=True)
return str(INTERNAL_DEST_DIR), False
mp = dev_info.get('mount')
if mp:
return mp, False
@@ -586,6 +749,12 @@ def _resolve_source_ports(cfg) -> list:
return ports
def _configured_destination(cfg, devs):
if cfg.get('dest_type') == 'internal':
return internal_dest_device(cfg)
return next((d for d in devs if d['usb_port'] == cfg.get('dest_port')), None)
def do_copy(src_devs, dst_dev, cfg):
"""Kopiert von einer oder mehreren Quellen auf ein Ziel."""
dst_mp = None
@@ -849,7 +1018,9 @@ def do_copy(src_devs, dst_dev, cfg):
def check_auto_copy():
cfg = load_cfg()
src_ports = _resolve_source_ports(cfg)
if not cfg.get('auto_copy') or not src_ports or not cfg.get('dest_port'):
if not cfg.get('auto_copy') or not src_ports:
return
if cfg.get('dest_type') != 'internal' and not cfg.get('dest_port'):
return
with copy_lock:
if copy_state['running'] or copy_state['error']:
@@ -857,7 +1028,7 @@ def check_auto_copy():
devs = usb_devices()
srcs = [next((d for d in devs if d['usb_port'] == sp['port']), None) for sp in src_ports]
srcs = [s for s in srcs if s is not None]
dst = next((d for d in devs if d['usb_port'] == cfg['dest_port']), None)
dst = _configured_destination(cfg, devs)
if srcs and dst:
log.info(f'Auto-Copy: {len(srcs)} Quelle(n) und Ziel verbunden')
threading.Thread(target=do_copy, args=(srcs, dst, cfg), daemon=True).start()
@@ -1272,7 +1443,8 @@ def r_status():
ws = dict(wifi_state)
with wg_lock:
wgs = dict(wg_state)
return jsonify(copy=cs, wifi=ws, vpn=wgs)
share = internal_share_update_state()
return jsonify(copy=cs, wifi=ws, vpn=wgs, internal_share=share)
@app.route('/api/copy/start', methods=['POST'])
def r_start():
@@ -1292,7 +1464,7 @@ def r_start():
if wanted_ports is not None:
srcs = [s for s in srcs if s['usb_port'] in wanted_ports]
if not srcs: return jsonify(error='Keine Quellgeräte gefunden (Ports nicht verbunden)'), 400
dst = next((d for d in devs if d['usb_port'] == cfg.get('dest_port')), None)
dst = _configured_destination(cfg, devs)
if not dst: return jsonify(error='Zielgerät nicht gefunden'), 400
_copy_thread = threading.Thread(target=do_copy, args=(srcs, dst, cfg), daemon=True)
_copy_thread.start()
@@ -1304,6 +1476,21 @@ def r_cancel():
copy_state['running'] = False
return jsonify(ok=True)
@app.route('/api/internal-share/status')
def r_internal_share_status():
return jsonify(internal_share_update_state())
@app.route('/api/internal-share', methods=['POST'])
def r_internal_share_set():
data = request.get_json(force=True) or {}
enabled = bool(data.get('enabled'))
ok, err = set_internal_share_enabled(enabled)
if not ok:
return jsonify(error=err), 500
return jsonify(ok=True, status=internal_share_update_state())
@app.route('/api/wifi/scan')
def r_wifi_scan():
nets = scan_wifi_networks()
@@ -1561,6 +1748,9 @@ def _drop_browse_mount(port):
def get_browse_mp(dev):
if dev.get('internal'):
INTERNAL_DEST_DIR.mkdir(parents=True, exist_ok=True)
return str(INTERNAL_DEST_DIR)
port = dev.get('usb_port', '')
# Auto-mount vom System bevorzugen
@@ -1590,6 +1780,8 @@ def r_browse():
rpath = request.args.get('path', '').lstrip('/')
devs = usb_devices()
dev = internal_dest_device(load_cfg()) if port == '__internal__' else None
if dev is None:
dev = next((d for d in devs if d['usb_port'] == port), None)
if not dev:
return jsonify(error='Gerät nicht verbunden - bitte neu einstecken'), 404
@@ -2065,9 +2257,16 @@ body{background:var(--bg);color:var(--txt);font-family:-apple-system,BlinkMacSys
</div>
<div class="field">
<label>Bezeichnung</label>
<input type="text" id="dst-label" placeholder="z.B. rechter schwarzer Port">
<input type="text" id="dst-label" placeholder="z.B. Zielplatte oder Interner Speicher">
</div>
<div class="field">
<label>Zieltyp</label>
<select id="dst-type" onchange="onDestTypeChange()">
<option value="usb">USB-Laufwerk</option>
<option value="internal">Interner Speicher vom Raspberry Pi</option>
</select>
</div>
<div class="field" id="dst-usb-field">
<label>Port lernen - Gerät wählen</label>
<select id="dst-select">
<option value="">- Gerät einstecken, dann hier wählen -</option>
@@ -2075,7 +2274,18 @@ body{background:var(--bg);color:var(--txt);font-family:-apple-system,BlinkMacSys
</div>
<button class="btn pri" style="width:100%" onclick="assignPort('dest')">✓&nbsp;Als festes Ziel speichern</button>
<div id="dst-flash" class="flash" style="margin-top:.4rem"></div>
<div class="hint-box">Gerät in den gewünschten Port &rarr; aus Liste wählen &rarr; Speichern. Ab dann wird dieser Port immer als Ziel verwendet.</div>
<div class="hint-box" id="dst-hint">Gerät in den gewünschten Port &rarr; aus Liste wählen &rarr; Speichern. Ab dann wird dieser Port immer als Ziel verwendet.</div>
<div id="internal-share-box" style="display:none;margin-top:.75rem;padding:.65rem .75rem;background:var(--bg2);border:1px solid var(--brd);border-radius:.5rem">
<div style="display:flex;align-items:center;gap:.55rem;justify-content:space-between">
<div style="min-width:0">
<div style="font-weight:700;font-size:.83rem">SMB-Freigabe</div>
<div id="internal-share-detail" style="font-size:.72rem;color:var(--sub);margin-top:.15rem"></div>
</div>
<button class="btn sm" id="internal-share-btn" onclick="toggleInternalShare()">Freigeben</button>
</div>
<div id="internal-share-flash" class="flash" style="margin-top:.35rem"></div>
</div>
</div>
</div><!-- /port-pair -->
@@ -2421,12 +2631,17 @@ function toggleSrc(port, on){
function renderExplorerTabs(){
const ports = cfg.source_ports || [];
$('src-tabs').innerHTML = ports.map((sp, i) => {
let tabs = ports.map((sp, i) => {
const r = 'src_'+i;
const label = sp.label || ('Quelle '+(i+1));
return `<button class="etab ${expl.role===r?'on':''}" id="etab-${r}"
onclick="expl.switchRole('${r}')">&#9650; ${label}</button>`;
}).join('');
if((cfg.dest_type||'usb')==='internal'){
tabs += `<button class="etab ${expl.role==='dst'?'on':''}" id="etab-dst"
onclick="expl.switchRole('dst')">&#9660; Intern</button>`;
}
$('src-tabs').innerHTML = tabs;
// Fallback: falls aktive Rolle nicht mehr existiert
if(expl.role!=='dst' && !ports.some((_,i)=>expl.role==='src_'+i)){
expl.role = ports.length>0 ? 'src_0' : 'dst';
@@ -2434,6 +2649,16 @@ function renderExplorerTabs(){
}
function renderSlot(r, port, label){
if(r==='dst' && (cfg.dest_type||'usb')==='internal'){
const dot=$('dst-dot'), pp=$('dst-port-path'), pi=$('dst-dev-info');
const sl=$('slot-dst'), lb=$('dst-label');
sl.classList.add('dst-on');
dot.className='dot on';
pp.textContent='Interner Speicher';
pi.textContent=(label||cfg.internal_dest_label||'Interner Speicher')+' | /opt/picopy/internal';
if(lb && !lb.dataset.dirty) lb.value=label||cfg.internal_dest_label||'Interner Speicher';
return;
}
const dev=devs.find(d=>d.usb_port===port);
const dot=$(r+'-dot'), pp=$(r+'-port-path'), pi=$(r+'-dev-info');
const sl=$('slot-'+r), lb=$(r+'-label');
@@ -2457,7 +2682,7 @@ function populateSel(){
const srcEl=$('src-select'), srcPrev=srcEl.value;
srcEl.innerHTML = blank('Gerät einstecken, dann hier wählen')
+ mkOpts(d => !srcSet.has(d.usb_port) && d.usb_port !== cfg.dest_port);
+ mkOpts(d => !srcSet.has(d.usb_port) && ((cfg.dest_type||'usb')==='internal' || d.usb_port !== cfg.dest_port));
if(srcPrev && devs.find(d=>d.usb_port===srcPrev)) srcEl.value=srcPrev;
const dstEl=$('dst-select'), dstPrev=dstEl.value;
@@ -2466,9 +2691,23 @@ function populateSel(){
if(dstPrev && devs.find(d=>d.usb_port===dstPrev)) dstEl.value=dstPrev;
}
function onDestTypeChange(markDirty=true){
const type=$('dst-type').value;
$('dst-usb-field').style.display=type==='internal'?'none':'';
$('internal-share-box').style.display=type==='internal'?'block':'none';
$('dst-hint').textContent=type==='internal'
? 'Kopiert auf /opt/picopy/internal. Die Daten können optional als SMB-Freigabe im Netzwerk bereitgestellt werden.'
: 'Gerät in den gewünschten Port → aus Liste wählen → Speichern. Ab dann wird dieser Port immer als Ziel verwendet.';
if(type==='internal' && !$('dst-label').value) $('dst-label').value='Interner Speicher';
if(markDirty) $('dst-label').dataset.dirty='1';
updateInternalShareBox();
renderSlot('dst',cfg.dest_port,cfg.dest_label);
renderExplorerTabs();
}
function renderUnassigned(){
const srcSet = new Set((cfg.source_ports||[]).map(sp=>sp.port));
const list=devs.filter(d=>!srcSet.has(d.usb_port)&&d.usb_port!==cfg.dest_port);
const list=devs.filter(d=>!srcSet.has(d.usb_port)&&(((cfg.dest_type||'usb')==='internal')||d.usb_port!==cfg.dest_port));
const w=$('unassigned-wrap');
if(!list.length){w.style.display='none';return;}
w.style.display='block';
@@ -2483,7 +2722,7 @@ function renderUnassigned(){
async function addSource(){
const port=$('src-select').value, label=$('src-label').value.trim();
if(!port){flash('src-flash','err','Bitte zuerst ein Gerät wählen.');return;}
if(port===cfg.dest_port){flash('src-flash','err','Port bereits als Ziel konfiguriert!');return;}
if((cfg.dest_type||'usb')!=='internal' && port===cfg.dest_port){flash('src-flash','err','Port bereits als Ziel konfiguriert!');return;}
if((cfg.source_ports||[]).some(sp=>sp.port===port)){flash('src-flash','err','Port bereits als Quelle hinzugefügt!');return;}
cfg.source_ports = [...(cfg.source_ports||[]), {port, label}];
selectedPortSet.add(port);
@@ -2503,9 +2742,22 @@ async function removeSource(port){
async function assignPort(role){
const sid='dst-select', lid='dst-label';
const fid='dst-flash', pk='dest_port', lk='dest_label';
const type=$('dst-type').value;
const port=$(sid).value, label=$(lid).value.trim();
if(type==='internal'){
cfg.dest_type='internal';
cfg.internal_dest_label=label||'Interner Speicher';
cfg[lk]=cfg.internal_dest_label;
$(lid).dataset.dirty='';
await api('/config','POST',cfg);
flash(fid,'ok','✓ Interner Speicher als Ziel gespeichert.');
renderSlot('dst',cfg.dest_port,cfg.dest_label);
renderExplorerTabs(); expl.reload();
return;
}
if(!port){flash(fid,'err','Bitte zuerst ein Gerät wählen.');return;}
if((cfg.source_ports||[]).some(sp=>sp.port===port)){flash(fid,'err','Port bereits als Quelle konfiguriert!');return;}
cfg.dest_type='usb';
cfg[pk]=port; cfg[lk]=label; $(lid).dataset.dirty='';
await api('/config','POST',cfg);
flash(fid,'ok','✓ Port '+port+' als Ziel gespeichert.');
@@ -2545,6 +2797,8 @@ async function loadCfg(){
$('c-verify').checked=!!cfg.verify_checksum;
$('c-delsrc').checked=!!cfg.delete_source;
$('w-ssid').value=cfg.wifi_ssid||''; $('ap-ssid').value=cfg.ap_ssid||'PiCopy';
$('dst-type').value=cfg.dest_type||'usb';
onDestTypeChange(false);
}
async function saveCopyCfg(){
cfg.folder_format=$('c-fmt').value; cfg.add_time=$('c-time').checked;
@@ -2681,6 +2935,37 @@ async function utDel(id,name){
await api('/upload/targets/'+id,'DELETE');await loadUTs();
}
async function updateInternalShareBox(state=null){
if(!$('internal-share-box'))return;
const s=state||await api('/internal-share/status');
const btn=$('internal-share-btn'), detail=$('internal-share-detail');
const free=s.free!=null?fmtBytes(s.free)+' frei':'';
if(s.pkg_running){
btn.disabled=true; btn.textContent='Installiere...';
detail.textContent='Samba wird installiert. '+free;
return;
}
btn.disabled=false;
btn.textContent=s.enabled?'Freigabe stoppen':'Freigeben';
const status=s.enabled
? ((s.active?'Aktiv':'Konfiguriert')+' | \\\\'+(location.hostname||'picopy')+'\\PiCopy')
: 'Nicht freigegeben';
detail.textContent=status+(free?' | '+free:'');
}
async function toggleInternalShare(){
const current=await api('/internal-share/status');
const enable=!current.enabled;
if(enable && !current.installed){
if(!confirm('Samba installieren und /opt/picopy/internal als Netzwerkfreigabe PiCopy bereitstellen?\n\nDie Freigabe ist lesbar im Netzwerk erreichbar.'))return;
}
flash('internal-share-flash','ok',enable?'Aktiviere Freigabe...':'Deaktiviere Freigabe...');
const r=await api('/internal-share','POST',{enabled:enable});
if(r.error){flash('internal-share-flash','err',r.error);return;}
flash('internal-share-flash','ok',enable?'✓ Freigabe aktiv':'✓ Freigabe deaktiviert');
updateInternalShareBox(r.status);
}
// -- File Explorer -------------------------------------------------------------
const expl={
role:'src_0', paths:{dst:''},
@@ -2695,14 +2980,16 @@ const expl={
async load(path=''){
let port;
if(this.role==='dst'){
port=cfg.dest_port;
port=(cfg.dest_type||'usb')==='internal'?'__internal__':cfg.dest_port;
} else {
const idx=parseInt(this.role.replace('src_',''),10);
port=cfg.source_ports&&cfg.source_ports[idx]?cfg.source_ports[idx].port:null;
}
const body=$('expl-body'), bread=$('expl-bread');
if(!port){body.innerHTML='<div class="expl-empty">Kein Port konfiguriert</div>';bread.innerHTML='';return;}
const dev=devs.find(d=>d.usb_port===port);
const dev=port==='__internal__'
? {usb_port:'__internal__',label:'Interner Speicher',device:'internal'}
: devs.find(d=>d.usb_port===port);
if(!dev){body.innerHTML='<div class="expl-empty">Gerät nicht verbunden</div>';bread.innerHTML='<span style="color:var(--sub)">Port '+port+' - nicht verbunden</span>';return;}
body.innerHTML='<div class="expl-empty">Lade...</div>';
try{
@@ -2782,7 +3069,8 @@ function fmtSpd(bps){
// -- Poll ----------------------------------------------------------------------
async function poll(){
try{
const {copy:c,wifi:w,vpn:v}=await api('/status');
const {copy:c,wifi:w,vpn:v,internal_share:is}=await api('/status');
if(is) updateInternalShareBox(is);
// VPN Topbar + Card
if(v){
const vp=$('vpn-pill'),vdot=$('vpn-dot'),vl=$('vpn-label'),vi=$('vpn-ip');

View File

@@ -1 +1 @@
1.0.50
1.0.51