Version 3.1 InDev

This commit is contained in:
2026-03-16 19:20:27 +02:00
parent 81e6e0a7a2
commit d7a6f43af1
224 changed files with 2168 additions and 14011 deletions

View File

@@ -0,0 +1,55 @@
# jibo-skills-logd
A tiny UDP logging daemon intended for **very old BusyBox + Python 2.7** robot environments.
## What it gives you
- A single place where all your scripts can log (JS, Python, shell)
- A single file you can `tail -f` on robot
- Minimal moving parts (no external deps)
## Files
- `tools/robot/logd/jibo_logd.py` — Python daemon (UDP → append to file)
- `tools/robot/init.d/jibo-skills-logd` — init.d service template
## Quick test (on robot)
Start the daemon in foreground:
- `python /opt/jibo/Jibo/Skills/tools/robot/logd/jibo_logd.py --host 127.0.0.1 --port 15140 --logfile /tmp/jibo-skills.log`
Send a message:
- `echo '{"tag":"test","level":"info","msg":"hello"}' | nc -u -w1 127.0.0.1 15140`
View:
- `tail -f /tmp/jibo-skills.log`
## Using from Node
In your skill code:
- `const rlog = require('@be/be/be/robot-logger');`
- `rlog.info('menu', 'injected entries', {count: 12});`
Env vars (optional):
- `JIBO_LOGD_HOST` (default `127.0.0.1`)
- `JIBO_LOGD_PORT` (default `15140`)
## Live web panel (optional)
There is also a tiny HTTP panel that streams the same logfile in real time (SSE).
- Script: `tools/robot/logpanel/jibo_logpanel.py`
- Init script: `/init.d/jibo-skills-logpanel`
Run (foreground):
- `python /opt/jibo/Jibo/Skills/tools/robot/logpanel/jibo_logpanel.py --bind 0.0.0.0 --port 15150 --logfile /tmp/jibo-skills.log`
Open in a browser:
- `http://<robot-ip>:15150/`

View File

@@ -0,0 +1,252 @@
#!/usr/bin/env python
# -*- coding: utf-8 -*-
"""jibo_logd.py
Tiny logging daemon for old BusyBox environments.
- Python 2.7 compatible
- Listens on UDP (default 127.0.0.1:15140)
- Appends newline-delimited logs to a file (default /tmp/jibo-skills.log)
- Optional daemonize + pidfile
Protocol:
- UDP payload may be plain text (written as-is)
- OR JSON object with optional fields: tag, level, msg, data
Examples:
echo '{"tag":"menu","level":"info","msg":"hello"}' | nc -u -w1 127.0.0.1 15140
"""
from __future__ import print_function
import argparse
import datetime
import errno
import json
import os
import signal
import socket
import sys
import time
DEFAULT_HOST = '127.0.0.1'
DEFAULT_PORT = 15140
DEFAULT_LOGFILE = '/tmp/jibo-skills.log'
DEFAULT_MAX_BYTES = 2 * 1024 * 1024
DEFAULT_BACKUPS = 3
class Logd(object):
def __init__(self, host, port, logfile, max_bytes, backups, flush_every):
self.host = host
self.port = port
self.logfile = logfile
self.max_bytes = max_bytes
self.backups = backups
self.flush_every = flush_every
self._sock = None
self._stop = False
self._line_count = 0
def stop(self, *_args):
self._stop = True
def _ensure_parent_dir(self):
parent = os.path.dirname(self.logfile)
if parent and not os.path.isdir(parent):
try:
os.makedirs(parent)
except OSError as e:
if e.errno != errno.EEXIST:
raise
def _rotate_if_needed(self):
if self.max_bytes <= 0:
return
try:
st = os.stat(self.logfile)
except OSError:
return
if st.st_size < self.max_bytes:
return
# Rotate: logfile -> logfile.1 -> logfile.2 ...
for i in range(self.backups, 0, -1):
src = self.logfile + ('' if i == 0 else '.%d' % i)
dst = self.logfile + '.%d' % (i + 1)
if i == self.backups:
# drop oldest
try:
os.unlink(self.logfile + '.%d' % (i + 1))
except OSError:
pass
try:
if os.path.exists(src):
os.rename(src, dst)
except OSError:
pass
try:
os.rename(self.logfile, self.logfile + '.1')
except OSError:
pass
def _format_line(self, payload, addr):
ts = datetime.datetime.utcnow().strftime('%Y-%m-%dT%H:%M:%S.%fZ')
src = '%s:%s' % (addr[0], addr[1])
txt = payload.strip()
if not txt:
return None
if txt.startswith('{') and txt.endswith('}'):
try:
obj = json.loads(txt)
tag = obj.get('tag', 'skill')
level = obj.get('level', 'info')
msg = obj.get('msg', '')
data = obj.get('data', None)
if data is not None:
try:
data_txt = json.dumps(data, separators=(',', ':'), sort_keys=True)
except Exception:
data_txt = repr(data)
return '%s [%s] %s %s %s\n' % (ts, level, tag, msg, data_txt)
return '%s [%s] %s %s\n' % (ts, level, tag, msg)
except Exception:
# fall back to raw
pass
return '%s [info] raw %s %s\n' % (ts, src, txt)
def serve_forever(self):
self._ensure_parent_dir()
self._sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
self._sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
self._sock.bind((self.host, self.port))
self._sock.settimeout(0.5)
f = None
try:
f = open(self.logfile, 'a')
while not self._stop:
try:
data, addr = self._sock.recvfrom(65535)
except socket.timeout:
continue
except socket.error:
continue
try:
if isinstance(data, bytes):
try:
payload = data.decode('utf-8', 'replace')
except Exception:
payload = str(data)
else:
payload = str(data)
except Exception:
continue
line = self._format_line(payload, addr)
if not line:
continue
self._rotate_if_needed()
try:
f.write(line)
self._line_count += 1
if self.flush_every > 0 and (self._line_count % self.flush_every) == 0:
try:
f.flush()
os.fsync(f.fileno())
except Exception:
pass
except Exception:
# best effort: reopen file if it moved/rotated
try:
f.close()
except Exception:
pass
try:
f = open(self.logfile, 'a')
except Exception:
time.sleep(0.25)
finally:
try:
if f:
f.flush()
f.close()
except Exception:
pass
try:
if self._sock:
self._sock.close()
except Exception:
pass
def daemonize(pidfile):
# Double-fork daemonize (POSIX)
try:
pid = os.fork()
if pid > 0:
os._exit(0)
except OSError:
raise
os.setsid()
try:
pid = os.fork()
if pid > 0:
os._exit(0)
except OSError:
raise
# Redirect stdio
sys.stdout.flush()
sys.stderr.flush()
si = open('/dev/null', 'r')
so = open('/dev/null', 'a+')
se = open('/dev/null', 'a+')
os.dup2(si.fileno(), sys.stdin.fileno())
os.dup2(so.fileno(), sys.stdout.fileno())
os.dup2(se.fileno(), sys.stderr.fileno())
if pidfile:
try:
with open(pidfile, 'w') as pf:
pf.write(str(os.getpid()))
except Exception:
pass
def main(argv):
ap = argparse.ArgumentParser(description='Jibo Skills UDP log daemon')
ap.add_argument('--host', default=os.environ.get('JIBO_LOGD_HOST', DEFAULT_HOST))
ap.add_argument('--port', type=int, default=int(os.environ.get('JIBO_LOGD_PORT', str(DEFAULT_PORT))))
ap.add_argument('--logfile', default=os.environ.get('JIBO_LOGD_FILE', DEFAULT_LOGFILE))
ap.add_argument('--max-bytes', type=int, default=int(os.environ.get('JIBO_LOGD_MAX_BYTES', str(DEFAULT_MAX_BYTES))))
ap.add_argument('--backups', type=int, default=int(os.environ.get('JIBO_LOGD_BACKUPS', str(DEFAULT_BACKUPS))))
ap.add_argument('--flush-every', type=int, default=int(os.environ.get('JIBO_LOGD_FLUSH_EVERY', '1')))
ap.add_argument('--daemonize', action='store_true')
ap.add_argument('--pidfile', default=os.environ.get('JIBO_LOGD_PIDFILE', '/tmp/jibo-skills-logd.pid'))
args = ap.parse_args(argv)
logd = Logd(args.host, args.port, args.logfile, args.max_bytes, args.backups, args.flush_every)
signal.signal(signal.SIGTERM, logd.stop)
signal.signal(signal.SIGINT, logd.stop)
if args.daemonize:
daemonize(args.pidfile)
logd.serve_forever()
return 0
if __name__ == '__main__':
sys.exit(main(sys.argv[1:]))

View File

@@ -0,0 +1,503 @@
#!/usr/bin/env python
# -*- coding: utf-8 -*-
"""jibo_logpanel.py
Minimal HTTP log panel for old BusyBox + Python 2.7 environments.
- Serves a tiny HTML page at `/`
- Streams log lines via Server-Sent Events (SSE) at `/events`
- Tails a logfile (default: /tmp/jibo-skills.log)
This is intentionally dependency-free (stdlib only).
"""
from __future__ import print_function
import argparse
import os
import sys
import time
try:
import json # stdlib
except Exception:
json = None
try:
import urlparse # Py2
except ImportError:
from urllib import parse as urlparse # Py3
try:
from BaseHTTPServer import HTTPServer, BaseHTTPRequestHandler
except ImportError:
# Py3 fallback (dev hosts)
from http.server import HTTPServer, BaseHTTPRequestHandler
try:
from SocketServer import ThreadingMixIn
except ImportError:
from socketserver import ThreadingMixIn
INDEX_HTML = """<!doctype html>
<html>
<head>
<meta charset=\"utf-8\" />
<meta name=\"viewport\" content=\"width=device-width, initial-scale=1\" />
<title>Jibo Skills Logs</title>
<style>
body { font-family: sans-serif; margin: 0; }
header { padding: 10px 12px; background: #111; color: #eee; position: sticky; top: 0; }
#status { opacity: 0.8; font-size: 12px; }
#controls { margin-top: 6px; font-size: 12px; }
#log { white-space: pre-wrap; font-family: monospace; font-size: 12px; padding: 10px 12px; }
.dim { opacity: 0.65; }
button { font-size: 12px; padding: 4px 8px; }
input { font-size: 12px; padding: 4px 6px; }
</style>
</head>
<body>
<header>
<div><strong>Jibo Skills Logs</strong> <span id=\"status\" class=\"dim\">(connecting)</span></div>
<div id=\"controls\">
<button id=\"pause\">Pause</button>
<button id=\"clear\">Clear</button>
<label class=\"dim\">Filter:</label>
<input id=\"filter\" placeholder=\"e.g. MENU-PATCH or [error]\" size=\"28\" />
</div>
</header>
<div id=\"log\"></div>
<script>
var logEl = document.getElementById('log');
var statusEl = document.getElementById('status');
var pauseBtn = document.getElementById('pause');
var clearBtn = document.getElementById('clear');
var filterEl = document.getElementById('filter');
var paused = false;
var filter = '';
var es = null;
var pollTimer = null;
var pollPos = 0;
function requestJSON(url, onOk, onErr) {
// Older embedded browsers may not have fetch(); use XHR.
try {
if (typeof fetch === 'function') {
fetch(url).then(function(r){ return r.json(); }).then(onOk).catch(onErr);
return;
}
} catch (e) { /* fall through */ }
try {
var xhr = new XMLHttpRequest();
xhr.open('GET', url, true);
xhr.onreadystatechange = function() {
if (xhr.readyState !== 4) return;
if (xhr.status >= 200 && xhr.status < 300) {
try {
onOk(JSON.parse(xhr.responseText));
} catch (e) {
onErr(e);
}
} else {
onErr(new Error('HTTP ' + xhr.status));
}
};
xhr.send(null);
} catch (e) {
onErr(e);
}
}
function setStatus(s) { statusEl.textContent = s; }
function appendLine(line) {
if (paused) return;
if (filter && line.indexOf(filter) === -1) return;
logEl.textContent += line + "\\n";
window.scrollTo(0, document.body.scrollHeight);
}
function stopPolling() {
if (pollTimer) { clearInterval(pollTimer); pollTimer = null; }
}
function startPolling() {
if (pollTimer) return;
setStatus('(polling)');
function tick() {
var url = 'tail?pos=' + encodeURIComponent(String(pollPos)) + '&max=8192';
requestJSON(url, function(obj){
if (!obj) return;
if (typeof obj.pos === 'number') pollPos = obj.pos;
if (obj.lines && obj.lines.length) {
for (var i=0; i<obj.lines.length; i++) appendLine(obj.lines[i]);
}
}, function(){ /* ignore */ });
}
tick();
pollTimer = setInterval(tick, 500);
}
function connect() {
if (es) { try { es.close(); } catch(e) {} }
stopPolling();
setStatus('(connecting)');
var opened = false;
// If EventSource isn't supported, go straight to polling.
if (typeof EventSource !== 'function') {
startPolling();
return;
}
try {
es = new EventSource('events?lines=200');
} catch (e) {
startPolling();
return;
}
var openTimeout = setTimeout(function(){
if (!opened) startPolling();
}, 1500);
es.onopen = function() {
opened = true;
clearTimeout(openTimeout);
setStatus('(live)');
stopPolling();
};
es.onerror = function() {
setStatus('(disconnected; retrying)');
startPolling();
};
es.onmessage = function(ev) { appendLine(ev.data); };
}
pauseBtn.onclick = function() {
paused = !paused;
pauseBtn.textContent = paused ? 'Resume' : 'Pause';
};
clearBtn.onclick = function() {
logEl.textContent = '';
};
filterEl.oninput = function() { filter = filterEl.value || ''; };
connect();
</script>
</body>
</html>
"""
def _tail_last_lines(path, max_lines):
if max_lines <= 0:
return []
try:
f = open(path, 'rb')
except Exception:
return []
try:
# naive but safe: read entire file if small, else chunk backwards
try:
size = os.path.getsize(path)
except Exception:
size = 0
if size <= 0:
return []
# read up to ~256KB from end
read_size = min(size, 256 * 1024)
f.seek(-read_size, os.SEEK_END)
data = f.read(read_size)
try:
txt = data.decode('utf-8', 'replace')
except Exception:
try:
txt = data.decode('latin-1', 'replace')
except Exception:
txt = str(data)
lines = txt.splitlines()
if len(lines) > max_lines:
lines = lines[-max_lines:]
return lines
finally:
try:
f.close()
except Exception:
pass
class ThreadedHTTPServer(ThreadingMixIn, HTTPServer):
daemon_threads = True
class Handler(BaseHTTPRequestHandler):
server_version = "JiboLogPanel/0.1"
# Use HTTP/1.0 semantics to avoid needing chunked encoding.
protocol_version = "HTTP/1.0"
try:
_text_type = unicode # Py2
except NameError:
_text_type = str # Py3
def _send(self, code, content_type, body):
if isinstance(body, self._text_type):
body = body.encode('utf-8')
self.send_response(code)
self.send_header('Content-Type', content_type)
self.send_header('Content-Length', str(len(body)))
self.send_header('Cache-Control', 'no-cache')
self.end_headers()
self.wfile.write(body)
def do_GET(self):
parsed = urlparse.urlparse(self.path)
path = parsed.path
qs = urlparse.parse_qs(parsed.query)
if path == '/' or path == '/index.html':
return self._send(200, 'text/html; charset=utf-8', INDEX_HTML)
if path == '/health':
return self._send(200, 'text/plain; charset=utf-8', 'ok')
if path == '/events':
return self._handle_events(qs)
if path == '/tail':
return self._handle_tail(qs)
return self._send(404, 'text/plain; charset=utf-8', 'not found')
def _handle_tail(self, qs):
logfile = getattr(self.server, 'logfile', '/tmp/jibo-skills.log')
try:
pos = int((qs.get('pos') or ['0'])[0])
except Exception:
pos = 0
try:
max_bytes = int((qs.get('max') or ['8192'])[0])
except Exception:
max_bytes = 8192
if max_bytes <= 0:
max_bytes = 8192
if max_bytes > 65536:
max_bytes = 65536
out = { 'pos': 0, 'lines': [] }
try:
st = os.stat(logfile)
size = st.st_size
if pos < 0 or pos > size:
pos = 0
with open(logfile, 'rb') as f:
f.seek(pos)
data = f.read(max_bytes)
new_pos = f.tell()
except Exception:
out['pos'] = 0
out['lines'] = []
body = json.dumps(out) if json else '{"pos":0,"lines":[]}'
return self._send(200, 'application/json; charset=utf-8', body)
try:
try:
txt = data.decode('utf-8', 'replace')
except Exception:
txt = data.decode('latin-1', 'replace')
# splitlines() drops trailing newline; that's fine for display
lines = txt.replace('\r', '').splitlines()
except Exception:
lines = []
out['pos'] = new_pos
out['lines'] = lines
body = json.dumps(out) if json else '{"pos":%d,"lines":[]}' % new_pos
return self._send(200, 'application/json; charset=utf-8', body)
def _handle_events(self, qs):
logfile = getattr(self.server, 'logfile', '/tmp/jibo-skills.log')
try:
lines = int((qs.get('lines') or ['0'])[0])
except Exception:
lines = 0
self.send_response(200)
self.send_header('Content-Type', 'text/event-stream; charset=utf-8')
self.send_header('Cache-Control', 'no-cache')
self.send_header('X-Accel-Buffering', 'no')
self.send_header('Access-Control-Allow-Origin', '*')
self.send_header('Connection', 'keep-alive')
self.end_headers()
# Ensure the connection stays open.
try:
self.close_connection = False
except Exception:
pass
def _b(s):
try:
if isinstance(s, bytes):
return s
except Exception:
pass
try:
if isinstance(s, self._text_type):
return s.encode('utf-8')
except Exception:
pass
try:
return str(s).encode('utf-8')
except Exception:
return b''
def _write(data_bytes):
if not data_bytes:
return
try:
self.wfile.write(data_bytes)
try:
self.wfile.flush()
except Exception:
pass
except Exception:
raise
# Advise client to retry quickly.
try:
_write(_b('retry: 1000\n\n'))
except Exception:
return
# initial payload: last N lines
try:
for line in _tail_last_lines(logfile, lines):
_write(_b('data: ' + line.replace('\r', '') + '\n\n'))
except Exception:
pass
# follow
f = None
last_inode = None
try:
try:
st = os.stat(logfile)
last_inode = st.st_ino
except Exception:
last_inode = None
last_ping = time.time()
def _try_open_follow():
try:
fh = open(logfile, 'rb')
fh.seek(0, os.SEEK_END)
return fh
except Exception:
return None
f = _try_open_follow()
while True:
# handle rotation: reopen if inode changes
try:
st2 = os.stat(logfile)
if last_inode is not None and st2.st_ino != last_inode:
try:
if f:
f.close()
except Exception:
pass
try:
f = open(logfile, 'rb')
f.seek(0, os.SEEK_END)
last_inode = st2.st_ino
except Exception:
f = None
except Exception:
pass
if not f:
# keep-alive heartbeat even if file missing
now = time.time()
if now - last_ping >= 5.0:
last_ping = now
try:
_write(_b(': ping\n\n'))
except Exception:
break
time.sleep(0.5)
continue
pos = f.tell()
chunk = f.readline()
if not chunk:
f.seek(pos)
now = time.time()
if now - last_ping >= 5.0:
last_ping = now
try:
_write(_b(': ping\n\n'))
except Exception:
break
time.sleep(0.25)
continue
try:
try:
line = chunk.decode('utf-8', 'replace')
except Exception:
line = chunk.decode('latin-1', 'replace')
line = line.rstrip('\n').rstrip('\r')
_write(_b('data: ' + line + '\n\n'))
except Exception:
# client disconnected
break
finally:
try:
if f:
f.close()
except Exception:
pass
def log_message(self, fmt, *args):
# keep quiet (panel should not spam stdout)
return
def main(argv):
ap = argparse.ArgumentParser(description='Jibo Skills Log Panel (SSE)')
ap.add_argument('--bind', default=os.environ.get('JIBO_LOGPANEL_BIND', '0.0.0.0'))
ap.add_argument('--port', type=int, default=int(os.environ.get('JIBO_LOGPANEL_PORT', '15150')))
ap.add_argument('--logfile', default=os.environ.get('JIBO_LOGD_FILE', '/tmp/jibo-skills.log'))
args = ap.parse_args(argv)
httpd = ThreadedHTTPServer((args.bind, args.port), Handler)
httpd.logfile = args.logfile
print('logpanel listening on %s:%d, logfile=%s' % (args.bind, args.port, args.logfile))
try:
httpd.serve_forever()
except KeyboardInterrupt:
pass
return 0
if __name__ == '__main__':
sys.exit(main(sys.argv[1:]))