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,99 @@
#!/bin/sh
### BEGIN INIT INFO
# Provides: jibo-skills-logd
# Required-Start: $local_fs
# Required-Stop: $local_fs
# Default-Start: 2 3 4 5
# Default-Stop: 0 1 6
# Short-Description: UDP log daemon for Jibo Skills
### END INIT INFO
# Install path on robot:
# /etc/init.d/jibo-skills-logd (this file)
# and ensure executable: chmod +x /etc/init.d/jibo-skills-logd
# Enable (varies by distro):
# update-rc.d jibo-skills-logd defaults
# or (BusyBox init): create symlink in /etc/rc.d/rcS.d/ or /etc/rc?.d/
PYTHON_BIN=${PYTHON_BIN:-/usr/bin/python}
DAEMON=${DAEMON:-/opt/jibo/Jibo/Skills/tools/robot/logd/jibo_logd.py}
PIDFILE=${PIDFILE:-/tmp/jibo-skills-logd.pid}
HOST=${JIBO_LOGD_HOST:-127.0.0.1}
PORT=${JIBO_LOGD_PORT:-15140}
LOGFILE=${JIBO_LOGD_FILE:-/tmp/jibo-skills.log}
start() {
echo "Starting jibo-skills-logd"
if [ -f "$PIDFILE" ]; then
PID=$(cat "$PIDFILE" 2>/dev/null)
if [ -n "$PID" ] && kill -0 "$PID" 2>/dev/null; then
echo "Already running (pid $PID)"
return 0
fi
fi
# best effort: ensure logfile dir exists
mkdir -p "$(dirname "$LOGFILE")" 2>/dev/null
"$PYTHON_BIN" "$DAEMON" --host "$HOST" --port "$PORT" --logfile "$LOGFILE" --daemonize --pidfile "$PIDFILE"
sleep 1
if [ -f "$PIDFILE" ]; then
echo "Started (pid $(cat "$PIDFILE" 2>/dev/null))"
return 0
fi
echo "Failed to start"
return 1
}
stop() {
echo "Stopping jibo-skills-logd"
if [ ! -f "$PIDFILE" ]; then
echo "Not running (no pidfile)"
return 0
fi
PID=$(cat "$PIDFILE" 2>/dev/null)
if [ -z "$PID" ]; then
rm -f "$PIDFILE"
return 0
fi
kill "$PID" 2>/dev/null
sleep 1
kill -9 "$PID" 2>/dev/null
rm -f "$PIDFILE"
echo "Stopped"
return 0
}
status() {
if [ -f "$PIDFILE" ]; then
PID=$(cat "$PIDFILE" 2>/dev/null)
if [ -n "$PID" ] && kill -0 "$PID" 2>/dev/null; then
echo "Running (pid $PID)"
return 0
fi
fi
echo "Not running"
return 3
}
case "$1" in
start)
start
;;
stop)
stop
;;
restart)
stop
start
;;
status)
status
;;
*)
echo "Usage: $0 {start|stop|restart|status}"
exit 1
;;
esac
exit $?

View File

@@ -0,0 +1,90 @@
#!/bin/sh
### BEGIN INIT INFO
# Provides: jibo-skills-logpanel
# Required-Start: $local_fs
# Required-Stop: $local_fs
# Default-Start: 2 3 4 5
# Default-Stop: 0 1 6
# Short-Description: HTTP panel for live Jibo Skills logs
### END INIT INFO
PYTHON_BIN=${PYTHON_BIN:-/usr/bin/python}
DAEMON=${DAEMON:-/opt/jibo/Jibo/Skills/tools/robot/logpanel/jibo_logpanel.py}
PIDFILE=${PIDFILE:-/tmp/jibo-skills-logpanel.pid}
BIND=${JIBO_LOGPANEL_BIND:-0.0.0.0}
PORT=${JIBO_LOGPANEL_PORT:-15150}
LOGFILE=${JIBO_LOGD_FILE:-/tmp/jibo-skills.log}
start() {
echo "Starting jibo-skills-logpanel"
if [ -f "$PIDFILE" ]; then
PID=$(cat "$PIDFILE" 2>/dev/null)
if [ -n "$PID" ] && kill -0 "$PID" 2>/dev/null; then
echo "Already running (pid $PID)"
return 0
fi
fi
"$PYTHON_BIN" "$DAEMON" --bind "$BIND" --port "$PORT" --logfile "$LOGFILE" >/tmp/jibo-skills-logpanel.log 2>&1 &
echo $! > "$PIDFILE"
sleep 1
if [ -f "$PIDFILE" ]; then
echo "Started (pid $(cat "$PIDFILE" 2>/dev/null))"
return 0
fi
echo "Failed to start"
return 1
}
stop() {
echo "Stopping jibo-skills-logpanel"
if [ ! -f "$PIDFILE" ]; then
echo "Not running (no pidfile)"
return 0
fi
PID=$(cat "$PIDFILE" 2>/dev/null)
if [ -z "$PID" ]; then
rm -f "$PIDFILE"
return 0
fi
kill "$PID" 2>/dev/null
sleep 1
kill -9 "$PID" 2>/dev/null
rm -f "$PIDFILE"
echo "Stopped"
return 0
}
status() {
if [ -f "$PIDFILE" ]; then
PID=$(cat "$PIDFILE" 2>/dev/null)
if [ -n "$PID" ] && kill -0 "$PID" 2>/dev/null; then
echo "Running (pid $PID)"
return 0
fi
fi
echo "Not running"
return 3
}
case "$1" in
start)
start
;;
stop)
stop
;;
restart)
stop
start
;;
status)
status
;;
*)
echo "Usage: $0 {start|stop|restart|status}"
exit 1
;;
esac
exit $?

View File

@@ -0,0 +1,127 @@
#!/bin/sh
#
# Jibo Firewall init script
#
set -e
IPTABLES_CMDS="/usr/sbin/iptables /usr/sbin/ip6tables"
flush_rules() {
for iptables in $IPTABLES_CMDS; do
$iptables -t filter -F
$iptables -t filter -P INPUT ACCEPT
$iptables -t filter -P FORWARD ACCEPT
$iptables -t filter -P OUTPUT ACCEPT
# add the DYNAMIC_ACCESS chain unconditionally
$iptables -t filter -X
$iptables -t filter -N DYNAMIC_ACCESS
done
}
normal_rules() {
for iptables in $IPTABLES_CMDS; do
$iptables -t filter -A INPUT -m state --state ESTABLISHED,RELATED -j ACCEPT
$iptables -t filter -A INPUT -p icmp -j ACCEPT
$iptables -t filter -A INPUT -i lo -j ACCEPT
# --- Custom Allowed Ports ---
# Allow SSH
$iptables -t filter -A INPUT -p tcp --dport 22 -j ACCEPT
# Allow Jibo Skills Service panel at 8779
$iptables -t filter -A INPUT -p tcp --dport 8779 -j ACCEPT
# Allow Custom Port 15150 for loggging
$iptables -t filter -A INPUT -p tcp --dport 15150 -j ACCEPT
# ----------------------------
# allow dynamic access rules from system-manager
$iptables -t filter -A INPUT -j DYNAMIC_ACCESS
# Reject everything else
$iptables -t filter -A INPUT -j REJECT
$iptables -t filter -A FORWARD -j REJECT
done
}
developer_rules() {
for iptables in $IPTABLES_CMDS; do
# jibo-dev-shell
$iptables -t filter -A INPUT -p tcp --syn --dport 8686 -j ACCEPT
# jibo-skills-service
$iptables -t filter -A INPUT -p tcp --syn --dport 8779 -j ACCEPT
# jibo-sync
$iptables -t filter -A INPUT -p tcp --syn --dport 8989 -j ACCEPT
# jibo-debug-proxy
$iptables -t filter -A INPUT -p tcp --syn --dport 9191 -j ACCEPT
# avahi
$iptables -t filter -A INPUT -p udp --dport 5353 -j ACCEPT
done
normal_rules
}
certification_rules() {
for iptables in $IPTABLES_CMDS; do
# jibo-certification-service
$iptables -t filter -A INPUT -p tcp --syn --dport 9292 -j ACCEPT
done
normal_rules
}
service_rules() {
for iptables in $IPTABLES_CMDS; do
# jibo-certification-service
$iptables -t filter -A INPUT -p tcp --syn --dport 9292 -j ACCEPT
# jibo-service-center-service
$iptables -t filter -A INPUT -p tcp --syn --dport 9797 -j ACCEPT
# avahi
$iptables -t filter -A INPUT -p udp --dport 5353 -j ACCEPT
done
normal_rules
}
start() {
echo -n "Configuring firewall: "
flush_rules
my_mode=$(/usr/bin/jibo-getmode)
if [ $? -ne 0 ]; then
echo "Unspecified mode. SKIP"
elif [ "$my_mode" == "identified" ]; then
echo "IDENTIFIED"
elif [ "$my_mode" == "int-developer" ]; then
echo "INT-DEVELOPER"
elif [ "$my_mode" == "developer" ]; then
developer_rules
test $? -eq 0 && echo "DEVELOPER" || echo "ERROR"
elif [ "$my_mode" == "certification" ]; then
certification_rules
test $? -eq 0 && echo "CERTIFICATION" || echo "ERROR"
elif [ "$my_mode" == "service" ]; then
service_rules
test $? -eq 0 && echo "SERVICE" || echo "ERROR"
else
normal_rules
test $? -eq 0 && echo "OK" || echo "ERROR"
fi
}
stop() {
echo -n "Unconfiguring firewall: "
flush_rules
test $? -eq 0 && echo "OK" || echo "ERROR"
}
case "$1" in
start)
start
;;
stop)
stop
;;
restart)
stop
start
;;
*)
echo "Usage: $0 {start|stop|restart}" >&2
exit 1
;;
esac

View File

@@ -0,0 +1,60 @@
"use strict";
// Minimal UDP logger client for old robot environments.
// Safe to require from any module; if logd is not running, it will just no-op.
const dgram = require("dgram");
const DEFAULT_HOST = process.env.JIBO_LOGD_HOST || "127.0.0.1";
const DEFAULT_PORT = parseInt(process.env.JIBO_LOGD_PORT || "15140", 10);
let _socket = null;
function getSocket() {
if (_socket) return _socket;
try {
_socket = dgram.createSocket("udp4");
_socket.unref();
return _socket;
} catch (e) {
return null;
}
}
function send(obj) {
const sock = getSocket();
if (!sock) return;
let payload;
try {
payload = Buffer.from(JSON.stringify(obj));
} catch (e) {
return;
}
try {
sock.send(payload, 0, payload.length, DEFAULT_PORT, DEFAULT_HOST, function () { });
} catch (e) {
// ignore
}
}
function mk(tag, level, msg, data) {
const o = { tag: tag || "skill", level: level || "info", msg: String(msg || "") };
if (typeof data !== "undefined") o.data = data;
return o;
}
exports.info = function (tag, msg, data) { send(mk(tag, "info", msg, data)); };
exports.warn = function (tag, msg, data) { send(mk(tag, "warn", msg, data)); };
exports.error = function (tag, msg, data) { send(mk(tag, "error", msg, data)); };
exports.debug = function (tag, msg, data) { send(mk(tag, "debug", msg, data)); };
exports.raw = function (line) {
const sock = getSocket();
if (!sock) return;
const payload = Buffer.from(String(line || ""));
try {
sock.send(payload, 0, payload.length, DEFAULT_PORT, DEFAULT_HOST, function () { });
} catch (e) { }
};

View File

@@ -0,0 +1,65 @@
# Menu providers (drop-in menu customization)
Providers let you add/override menu entries without editing the core menu patch.
Default entries directory (on robot):
- `/opt/jibo/Jibo/Skills/@be/menu-entries.d/`
Legacy fallback (yea ik its the same stop talking):
- `/opt/jibo/Jibo/Skills/@be/menu-providers.d/`
## Provider file types
### JSON provider (`*.json`)
Supported shapes:
- An array of entries: `[ { ... }, { ... } ]`
- Or `{ "entries": [ ... ] }`
Entry schema (same as `menuEntry.json` scan output):
- `id` (string, required)
- `type` (`skill` or `submenu`)
- `title`
- `icon`
- `color`
- `description`
- `order` (number)
- `skillId` (for type `skill`)
- `submenuTitle` (for type `submenu`)
- `children` (array of skill entries, for type `submenu`)
- `childrenDir` (for type `submenu`, optional):
- Absolute path (starts with `/`) or relative to `skillsRoot`
- If provided and `children` is missing/empty, the patch will scan this directory for child skills (subfolders containing `menuEntry.json`).
Example submenu that lists a directory:
```json
[
{
"id": "fun_stuff",
"type": "submenu",
"title": "Fun Stuff",
"icon": "resources/icons/fun-stuff.png",
"order": 20,
"childrenDir": "FunStuff"
}
]
```
### JS provider (`*.js`)
Exports one of:
- `module.exports = function(ctx) { return [ ...entries... ]; }`
- `exports.getEntries = function(ctx) { return [ ...entries... ]; }`
- `exports.entries = [ ...entries... ]`
`ctx` includes:
- `skillsRoot`
- `providersDir`
- `log` (function)
## Conflict rules
- If a provider entry has the same `id` as a scanned entry, the provider entry wins.
- Sorting: by `order` then by title.

View File

@@ -12,7 +12,7 @@ When Jibo boots up, the `main-menu-patch.js` module:
## Quick Start
### Adding a Simple Skill Button to the Menu
### Adding a Simple Skill Button to the Menu (you really should follow PROVIDERS.md at this point)
1. Create a folder in the `Skills` directory (e.g., `MySkill/`)
2. Add a `menuEntry.json` file inside:
@@ -53,7 +53,7 @@ When Jibo boots up, the `main-menu-patch.js` module:
3. Inside that folder, create subfolders for each skill, each with their own `menuEntry.json`
4. A button will appear in the main menu that opens a submenu with those skills!
## Directory Structure Example
## Directory Structure Example (OLD)
```
/opt/jibo/Jibo/Skills/

View File

@@ -10,11 +10,38 @@
const fs = require('fs');
const path = require('path');
// Skills root directory
const SKILLS_ROOT = '/opt/jibo/Jibo/Skills';
const menuEntries = require('./menu-entries');
const skillsRootUtil = require('./skills-root');
function strStartsWith(s, prefix) {
return (typeof s === 'string') && (s.indexOf(prefix) === 0);
}
function strEndsWith(s, suffix) {
if (typeof s !== 'string' || typeof suffix !== 'string') return false;
if (suffix.length > s.length) return false;
return s.indexOf(suffix, s.length - suffix.length) !== -1;
}
function strIncludes(s, needle) {
return (typeof s === 'string') && (s.indexOf(needle) !== -1);
}
// Optional UDP logger (Python logd). Safe fallback if unavailable.
let robotLogger = null;
try {
robotLogger = require('../be/robot-logger');
} catch (e) {
robotLogger = null;
}
// Skills root directory (configurable)
let SKILLS_ROOT = skillsRootUtil.resolveSkillsRoot();
// Cache for scanned skills
let _cachedMenuEntries = null;
let _cachedSkillsRoot = null;
let _cachedProvidersDir = null;
let _submenuConfigs = {};
let _dynamicSkillIds = new Set(); // Track dynamic skill IDs for routing
let _beInstance = null;
@@ -40,6 +67,11 @@ const LOG_FILE = '/tmp/menu-patch.log';
function log(msg, ...args) {
const line = '[MENU-PATCH] ' + msg + ' ' + args.map(a => typeof a === 'object' ? JSON.stringify(a) : String(a)).join(' ');
console.log(line);
try {
if (robotLogger && typeof robotLogger.raw === 'function') {
robotLogger.raw(line);
}
} catch (e) { }
try {
fs.appendFileSync(LOG_FILE, new Date().toISOString() + ' ' + line + '\n');
} catch (e) {}
@@ -48,6 +80,11 @@ function log(msg, ...args) {
function warn(msg, ...args) {
const line = '[MENU-PATCH WARN] ' + msg + ' ' + args.map(a => typeof a === 'object' ? JSON.stringify(a) : String(a)).join(' ');
console.warn(line);
try {
if (robotLogger && typeof robotLogger.raw === 'function') {
robotLogger.raw(line);
}
} catch (e) { }
try {
fs.appendFileSync(LOG_FILE, new Date().toISOString() + ' ' + line + '\n');
} catch (e) {}
@@ -172,59 +209,37 @@ function scanSubmenuChildren(submenuDir) {
* Scan the Skills root directory for all menu entries
*/
function scanAllMenuEntries() {
if (_cachedMenuEntries) {
if (_cachedMenuEntries && _cachedSkillsRoot === SKILLS_ROOT) {
log('Returning cached entries:', _cachedMenuEntries.length);
return _cachedMenuEntries;
}
log('Scanning Skills directory:', SKILLS_ROOT);
_dynamicSkillIds.clear();
const entries = [];
let children;
// Check if directory exists
if (!fs.existsSync(SKILLS_ROOT)) {
warn('Skills directory does not exist:', SKILLS_ROOT);
return entries;
}
const res = menuEntries.getMenuEntries({
skillsRoot: SKILLS_ROOT,
log: function () {
try { log.apply(null, arguments); } catch (e) { }
}
});
_cachedMenuEntries = res.entries || [];
_cachedSkillsRoot = res.skillsRoot;
_cachedProvidersDir = res.providersDir;
// update dynamic id set
try {
children = fs.readdirSync(SKILLS_ROOT);
log('Found', children.length, 'items in Skills directory:', children.join(', '));
} catch (e) {
warn('Failed to read skills directory:', SKILLS_ROOT, e.message);
return entries;
if (res.dynamicSkillIds && typeof res.dynamicSkillIds.forEach === 'function') {
res.dynamicSkillIds.forEach(function (id) { _dynamicSkillIds.add(id); });
}
} catch (e) { }
log('Total entries found:', _cachedMenuEntries.length);
if (_cachedProvidersDir) {
log('Providers dir:', _cachedProvidersDir);
}
children.forEach(name => {
if (name.startsWith('.') || name === '@be' || name === 'node_modules') {
log('Skipping:', name);
return;
}
const skillDir = path.join(SKILLS_ROOT, name);
if (!isDirectory(skillDir)) {
log('Not a directory:', name);
return;
}
const entry = scanSkillEntry(skillDir, name);
if (entry) {
entries.push(entry);
log('Added entry:', entry.title, '(type:', entry.type + ')');
}
});
// Sort by order, then alphabetically
entries.sort((a, b) => {
if (a.order !== b.order) return a.order - b.order;
return a.title.localeCompare(b.title);
});
log('Total entries found:', entries.length);
_cachedMenuEntries = entries;
return entries;
return _cachedMenuEntries;
}
/**
@@ -427,7 +442,7 @@ function patchMainMenuSkill(be) {
}
// Handle dynamic submenus
if (skill && skill.startsWith('dynamic-submenu-')) {
if (skill && strStartsWith(skill, 'dynamic-submenu-')) {
log('Showing submenu:', skill);
const submenuConfig = _submenuConfigs[skill];
if (submenuConfig && _jibo && _jibo.face && _jibo.face.views) {
@@ -443,11 +458,15 @@ function patchMainMenuSkill(be) {
if (result && result.on) {
result.on('select', (selection) => {
log('Submenu item selected:', selection);
if (selection && selection.action && selection.action.data) {
const destination = selection.action.data.utterance?.entities?.destination;
if (destination) {
this.redirectToSkill(destination);
try {
if (selection && selection.action && selection.action.data && selection.action.data.utterance && selection.action.data.utterance.entities) {
const destination = selection.action.data.utterance.entities.destination;
if (destination) {
this.redirectToSkill(destination);
}
}
} catch (e) {
warn('Submenu selection handler error:', e && e.message ? e.message : e);
}
});
}
@@ -459,7 +478,7 @@ function patchMainMenuSkill(be) {
}
// Handle dynamic skills (prefixed with 'dynamic:')
if (skill && skill.startsWith('dynamic:')) {
if (skill && strStartsWith(skill, 'dynamic:')) {
const actualSkillId = skill.substring(8); // Remove 'dynamic:' prefix
log('Launching dynamic skill:', actualSkillId);
@@ -530,6 +549,14 @@ function applyPatch(be) {
function patchLoader(jibo, skillsRoot) {
log('patchLoader called');
_jibo = jibo;
// Allow caller to override skills root.
if (skillsRoot && typeof skillsRoot === 'string') {
SKILLS_ROOT = skillsRoot;
} else {
SKILLS_ROOT = skillsRootUtil.resolveSkillsRoot();
}
clearCache();
if (!jibo) {
warn('jibo is null/undefined');
@@ -553,7 +580,7 @@ function patchLoader(jibo, skillsRoot) {
log('>>> jibo.loader.load called with:', resourcePath);
// Check if this is loading a dynamic submenu
if (resourcePath && resourcePath.startsWith('dynamic-submenu-')) {
if (resourcePath && strStartsWith(resourcePath, 'dynamic-submenu-')) {
const submenuConfig = _submenuConfigs[resourcePath];
if (submenuConfig) {
log('Loading dynamic submenu:', resourcePath);
@@ -566,10 +593,10 @@ function patchLoader(jibo, skillsRoot) {
// Check if this is the main menu - match various possible paths
const isMainMenu = resourcePath && (
resourcePath.includes('main-menu-verbal.json') ||
resourcePath.includes('main-menu.json') ||
strIncludes(resourcePath, 'main-menu-verbal.json') ||
strIncludes(resourcePath, 'main-menu.json') ||
resourcePath === 'resources/views/main-menu-verbal.json' ||
resourcePath.endsWith('main-menu-verbal.json')
strEndsWith(resourcePath, 'main-menu-verbal.json')
);
if (isMainMenu) {
@@ -584,12 +611,22 @@ function patchLoader(jibo, skillsRoot) {
}
log('Original menu config loaded successfully');
log('Original button count:', config?.viewConfig?.list?.length || 0);
try {
const origCount = (config && config.viewConfig && config.viewConfig.list && config.viewConfig.list.length) ? config.viewConfig.list.length : 0;
log('Original button count:', origCount);
} catch (e) {
log('Original button count: (unknown)');
}
// Inject dynamic skills
const patchedConfig = injectDynamicSkills(config);
log('Patched button count:', patchedConfig?.viewConfig?.list?.length || 0);
try {
const patchedCount = (patchedConfig && patchedConfig.viewConfig && patchedConfig.viewConfig.list && patchedConfig.viewConfig.list.length) ? patchedConfig.viewConfig.list.length : 0;
log('Patched button count:', patchedCount);
} catch (e) {
log('Patched button count: (unknown)');
}
if (callback) callback(null, patchedConfig);
});
@@ -608,6 +645,8 @@ function patchLoader(jibo, skillsRoot) {
*/
function clearCache() {
_cachedMenuEntries = null;
_cachedSkillsRoot = null;
_cachedProvidersDir = null;
_submenuConfigs = {};
_dynamicSkillIds.clear();
}

View File

@@ -0,0 +1,72 @@
"use strict";
const fs = require("fs");
const path = require("path");
const skillsRoot = require("./skills-root");
const scanner = require("./menu-entry-scanner");
const providers = require("./menu-providers");
function isDirectory(p) {
try {
return fs.statSync(p).isDirectory();
} catch (e) {
return false;
}
}
function expandSubmenuChildren(entries, root, opts) {
(entries || []).forEach(function (e) {
if (!e || e.type !== "submenu") return;
if (e.children && e.children.length) return;
if (!e.childrenDir) return;
let dir = e.childrenDir;
if (typeof dir !== "string" || dir.trim().length === 0) return;
dir = dir.trim();
const resolved = (dir.charAt(0) === "/") ? dir : path.join(root, dir);
if (!isDirectory(resolved)) {
if (opts && opts.log) opts.log("submenu childrenDir not a directory", e.id, resolved);
e.children = [];
return;
}
if (opts && opts.log) opts.log("submenu childrenDir scan", e.id, resolved);
e.children = scanner.scanSubmenuChildren(resolved, { log: opts && opts.log, defaultIcon: opts && opts.defaultIcon, defaultColor: opts && opts.defaultColor, defaultOrder: opts && opts.defaultOrder });
});
}
function getMenuEntries(opts) {
opts = opts || {};
const root = skillsRoot.resolveSkillsRoot(opts.skillsRoot);
const providerDir = opts.providersDir || skillsRoot.providersDirForRoot(root);
const scanned = scanner.scanAllMenuEntries(root, {
log: opts.log,
defaultIcon: opts.defaultIcon,
defaultColor: opts.defaultColor,
defaultOrder: opts.defaultOrder
});
const provided = providers.loadProviderEntries(providerDir, { log: opts.log, skillsRoot: root, providersDir: providerDir });
const merged = providers.mergeById(scanned, provided);
// Allow provider-defined submenus to be backed by an arbitrary directory.
// Example: { type: "submenu", id: "fun", childrenDir: "FunStuff" }
expandSubmenuChildren(merged, root, opts);
const dynamicIds = scanner.collectDynamicSkillIds(merged);
return {
skillsRoot: root,
providersDir: providerDir,
entries: merged,
dynamicSkillIds: dynamicIds
};
}
module.exports = {
getMenuEntries: getMenuEntries
};

View File

@@ -0,0 +1,150 @@
"use strict";
const fs = require("fs");
const path = require("path");
function safeReadJson(filePath, logFn) {
try {
const txt = fs.readFileSync(filePath, "utf8");
if (!txt || txt.trim().length === 0) return null;
return JSON.parse(txt);
} catch (e) {
if (logFn) logFn("safeReadJson failed", filePath, e && e.message ? e.message : e);
return null;
}
}
function isDirectory(p) {
try {
return fs.statSync(p).isDirectory();
} catch (e) {
return false;
}
}
function scanSkillEntry(skillDir, folderName, opts) {
opts = opts || {};
const menuJsonPath = path.join(skillDir, "menuEntry.json");
const menuJson = safeReadJson(menuJsonPath, opts.log);
if (!menuJson) return null;
if (menuJson.hidden === true) return null;
const entry = {
id: folderName,
type: menuJson.type || "skill",
title: menuJson.title || menuJson.label || folderName,
icon: menuJson.icon || menuJson.iconSrc || (opts.defaultIcon || "resources/icons/settings.png"),
color: menuJson.color || menuJson.colors || (opts.defaultColor || "default"),
description: menuJson.description || "",
path: skillDir,
order: typeof menuJson.order === "number" ? menuJson.order : (typeof opts.defaultOrder === "number" ? opts.defaultOrder : 100),
skillId: menuJson.skillId || folderName,
submenuTitle: menuJson.submenuTitle || menuJson.title || folderName
};
if (entry.type === "submenu") {
entry.children = scanSubmenuChildren(skillDir, opts);
}
// Legacy support
if (menuJson.isSubmenu === true && entry.type !== "submenu") {
entry.type = "submenu";
entry.children = scanSubmenuChildren(skillDir, opts);
}
return entry;
}
function scanSubmenuChildren(submenuDir, opts) {
const children = [];
let items;
try {
items = fs.readdirSync(submenuDir);
} catch (e) {
return children;
}
items.forEach(function (name) {
if (!name || name.charAt(0) === ".") return;
if (name === "@be" || name === "node_modules") return;
const childPath = path.join(submenuDir, name);
if (!isDirectory(childPath)) return;
const entry = scanSkillEntry(childPath, name, opts);
if (entry && entry.type === "skill") {
children.push(entry);
}
});
children.sort(function (a, b) {
if (a.order !== b.order) return a.order - b.order;
return String(a.title || "").localeCompare(String(b.title || ""));
});
return children;
}
function scanAllMenuEntries(skillsRoot, opts) {
opts = opts || {};
const entries = [];
if (!skillsRoot || !fs.existsSync(skillsRoot)) {
if (opts.log) opts.log("skills root missing", skillsRoot);
return entries;
}
let children;
try {
children = fs.readdirSync(skillsRoot);
} catch (e) {
if (opts.log) opts.log("failed to read skills root", skillsRoot, e && e.message ? e.message : e);
return entries;
}
children.forEach(function (name) {
if (!name || name.charAt(0) === ".") return;
if (name === "@be" || name === "node_modules") return;
const skillDir = path.join(skillsRoot, name);
if (!isDirectory(skillDir)) return;
const entry = scanSkillEntry(skillDir, name, opts);
if (entry) entries.push(entry);
});
entries.sort(function (a, b) {
if (a.order !== b.order) return a.order - b.order;
return String(a.title || "").localeCompare(String(b.title || ""));
});
return entries;
}
function collectDynamicSkillIds(entries) {
const ids = new Set();
(entries || []).forEach(function (e) {
if (!e) return;
if (e.type === "skill") {
if (e.skillId) ids.add(e.skillId);
if (e.id) ids.add(e.id);
}
if (e.type === "submenu" && e.children && e.children.length) {
e.children.forEach(function (c) {
if (!c) return;
if (c.skillId) ids.add(c.skillId);
if (c.id) ids.add(c.id);
});
}
});
return ids;
}
module.exports = {
scanAllMenuEntries: scanAllMenuEntries,
scanSubmenuChildren: scanSubmenuChildren,
collectDynamicSkillIds: collectDynamicSkillIds
};

View File

@@ -0,0 +1,115 @@
"use strict";
const fs = require("fs");
const path = require("path");
function isDirectory(p) {
try {
return fs.statSync(p).isDirectory();
} catch (e) {
return false;
}
}
function safeReadJson(filePath, logFn) {
try {
const txt = fs.readFileSync(filePath, "utf8");
if (!txt || txt.trim().length === 0) return null;
return JSON.parse(txt);
} catch (e) {
if (logFn) logFn("provider json read failed", filePath, e && e.message ? e.message : e);
return null;
}
}
function loadFromJson(filePath, ctx) {
const obj = safeReadJson(filePath, ctx && ctx.log);
if (!obj) return [];
if (Array.isArray(obj)) return obj;
if (obj && Array.isArray(obj.entries)) return obj.entries;
if (obj && Array.isArray(obj.buttons)) return obj.buttons;
return [];
}
function loadFromJs(filePath, ctx) {
try {
// eslint-disable-next-line global-require, import/no-dynamic-require
const mod = require(filePath);
if (!mod) return [];
if (typeof mod === "function") return mod(ctx) || [];
if (typeof mod.getEntries === "function") return mod.getEntries(ctx) || [];
if (Array.isArray(mod.entries)) return mod.entries;
return [];
} catch (e) {
if (ctx && ctx.log) ctx.log("provider js load failed", filePath, e && e.message ? e.message : e);
return [];
}
}
function loadProviderEntries(providersDir, ctx) {
const out = [];
if (!providersDir || !fs.existsSync(providersDir) || !isDirectory(providersDir)) {
return out;
}
let files;
try {
files = fs.readdirSync(providersDir);
} catch (e) {
return out;
}
files.sort();
files.forEach(function (name) {
if (!name || name.charAt(0) === ".") return;
const full = path.join(providersDir, name);
if (isDirectory(full)) return;
if (name.indexOf(".json", name.length - 5) !== -1) {
const entries = loadFromJson(full, ctx);
entries.forEach(function (e) { out.push(e); });
return;
}
if (name.indexOf(".js", name.length - 3) !== -1) {
const entries = loadFromJs(full, ctx);
entries.forEach(function (e) { out.push(e); });
}
});
return out;
}
// Merge provider entries into scanned entries by id.
// Provider entries win on conflicts.
function mergeById(scannedEntries, providerEntries) {
const byId = {};
(scannedEntries || []).forEach(function (e) {
if (!e || !e.id) return;
byId[e.id] = e;
});
(providerEntries || []).forEach(function (p) {
if (!p || !p.id) return;
byId[p.id] = p;
});
const merged = Object.keys(byId).map(function (k) { return byId[k]; });
merged.sort(function (a, b) {
const ao = typeof a.order === "number" ? a.order : 100;
const bo = typeof b.order === "number" ? b.order : 100;
if (ao !== bo) return ao - bo;
return String(a.title || a.label || a.id).localeCompare(String(b.title || b.label || b.id));
});
return merged;
}
module.exports = {
loadProviderEntries: loadProviderEntries,
mergeById: mergeById
};

View File

@@ -0,0 +1,31 @@
"use strict";
const path = require("path");
const fs = require("fs");
const DEFAULT_ROOT = "/opt/jibo/Jibo/Skills";
function resolveSkillsRoot(overrideRoot) {
if (overrideRoot && typeof overrideRoot === "string") return overrideRoot;
if (process && process.env && process.env.JIBO_SKILLS_ROOT) return process.env.JIBO_SKILLS_ROOT;
return DEFAULT_ROOT;
}
function providersDirForRoot(skillsRoot) {
// Keep providers inside Skills so they can be synced easily.
// Default: /opt/jibo/Jibo/Skills/@be/menu-entries.d
// Legacy fallback: /opt/jibo/Jibo/Skills/@be/menu-providers.d
const v2 = path.join(skillsRoot, "@be", "menu-entries.d");
const v1 = path.join(skillsRoot, "@be", "menu-providers.d");
try {
if (fs.existsSync(v2)) return v2;
if (fs.existsSync(v1)) return v1;
} catch (e) { /* ignore */ }
return v2;
}
module.exports = {
DEFAULT_ROOT: DEFAULT_ROOT,
resolveSkillsRoot: resolveSkillsRoot,
providersDirForRoot: providersDirForRoot
};

View File

@@ -0,0 +1,78 @@
const EventEmitter = require('events');
class LocalRadioPlayer extends EventEmitter {
constructor() {
super();
// These are example streams. You will need to set up your own
// Icecast/HTTP streams and replace these URLs with your server's IP.
this._stations = {
'Rock': 'http://192.168.1.100:8000/rock',
'Pop': 'http://192.168.1.100:8000/pop',
'Jazz': 'http://192.168.1.100:8000/jazz',
'Classical': 'http://192.168.1.100:8000/classical',
'Electronic': 'http://192.168.1.100:8000/electronic',
'Hip-Hop': 'http://192.168.1.100:8000/hiphop'
};
this._audio = null;
console.log("LocalRadioPlayer initialized");
}
getStations(options) {
console.log("LocalRadioPlayer: getStations called");
const stationList = Object.keys(this._stations).map(genre => ({
name: genre,
id: genre
}));
return Promise.resolve(stationList);
}
play(stationData) {
console.log(`LocalRadioPlayer: play called with`, stationData);
if (this._audio) {
this._audio.pause();
}
const streamUrl = this._stations[stationData.id];
if (!streamUrl) {
const err = new Error(`Station ${stationData.id} not found.`);
console.error("LocalRadioPlayer Error:", err);
this.emit('error', err);
return;
}
console.log(`LocalRadioPlayer: Playing stream from ${streamUrl}`);
this._audio = new Audio(streamUrl);
this._audio.addEventListener('error', (e) => {
console.error('LocalRadioPlayer Audio Error:', e);
this.emit('error', new Error('Audio playback failed.'));
});
this._audio.play()
.then(() => {
console.log("LocalRadioPlayer: Playback started.");
this.emit('song-data', {
title: `Streaming ${stationData.id}`,
artist: 'Local Radio',
albumArt: '' // No artwork for local streams
});
})
.catch(err => {
console.error("LocalRadioPlayer Playback Error:", err);
this.emit('error', err);
});
}
stop() {
console.log("LocalRadioPlayer: stop called");
if (this._audio) {
this._audio.pause();
this._audio = null;
}
}
resizeArtwork(options) {
// Not applicable for local streaming
return Promise.resolve('');
}
}
module.exports = LocalRadioPlayer;

View File

@@ -0,0 +1,73 @@
const EventEmitter = require('events');
class LocalRadioPlayer extends EventEmitter {
constructor() {
super();
// These are example streams. You will need to set up your own
// Icecast/HTTP streams and replace these URLs.
this._stations = {
'My Station': 'http://192.168.1.5:6767/Ninja%20Tuna.mp3'
};
this._audio = null;
console.log("LocalRadioPlayer initialized");
}
getStations(options) {
console.log("LocalRadioPlayer: getStations called");
const stationList = Object.keys(this._stations).map(genre => ({
name: genre,
id: genre
}));
return Promise.resolve(stationList);
}
play(stationData) {
console.log(`LocalRadioPlayer: play called with`, stationData);
if (this._audio) {
this._audio.pause();
}
const streamUrl = this._stations[stationData.id];
if (!streamUrl) {
const err = new Error(`Station ${stationData.id} not found.`);
console.error("LocalRadioPlayer Error:", err);
this.emit('error', err);
return;
}
console.log(`LocalRadioPlayer: Playing stream from ${streamUrl}`);
this._audio = new Audio(streamUrl);
this._audio.addEventListener('error', (e) => {
console.error('LocalRadioPlayer Audio Error:', e);
this.emit('error', new Error('Audio playback failed.'));
});
this._audio.play()
.then(() => {
console.log("LocalRadioPlayer: Playback started.");
this.emit('song-data', {
title: `Streaming ${stationData.id}`,
artist: 'Local Radio',
albumArt: '' // No artwork for local streams
});
})
.catch(err => {
console.error("LocalRadioPlayer Playback Error:", err);
this.emit('error', err);
});
}
stop() {
console.log("LocalRadioPlayer: stop called");
if (this._audio) {
this._audio.pause();
this._audio = null;
}
}
resizeArtwork(options) {
// Not applicable for local streaming
return Promise.resolve('');
}
}
module.exports = LocalRadioPlayer;

File diff suppressed because it is too large Load Diff

Binary file not shown.

After

Width:  |  Height:  |  Size: 79 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 173 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 62 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 177 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 24 KiB

After

Width:  |  Height:  |  Size: 31 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 14 KiB

After

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 111 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 107 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 76 KiB

View File

@@ -0,0 +1,13 @@
[
{
"id": "__example__",
"type": "submenu",
"title": "Example (Entry Dir)",
"submenuTitle": "Example Submenu",
"icon": "resources/icons/settings.png",
"color": "teal",
"description": "Example submenu from @be/menu-entries.d (scans childrenDir for menuEntry.json)",
"order": 5,
"childrenDir": "TestSubMenuA"
}
]

View File

@@ -0,0 +1,12 @@
[
{
"id": "__submenu_test__",
"type": "submenu",
"title": "Test Folder",
"submenuTitle": "Test Submenu",
"icon": "resources/icons/fun-stuff.png",
"color": "purple",
"order": 1,
"childrenDir": "FunStuffTest"
}
]

View File

@@ -0,0 +1,8 @@
{
"type": "skill",
"title": "Clock One (launch jibo-tbd)",
"icon": "resources/icons/clock.png",
"color": "blue",
"order": 20,
"skillId": "jibo-tbd"
}

View File

@@ -0,0 +1,8 @@
{
"type": "skill",
"title": "Fun One (launch jibo-tbd)",
"icon": "resources/icons/fun-stuff.png",
"color": "orange",
"order": 10,
"skillId": "jibo-tbd"
}

View File

@@ -0,0 +1,4 @@
{
"hidden": true,
"title": "FunStuffTest Root"
}

View File

@@ -0,0 +1,84 @@
# Skills logging (robot)
This modded version of the JiboOS includes a tiny “always works” logging stack for the robots old BusyBox + Python 2.7 environment:
- **UDP log daemon**: receives log messages and appends to a file
- **Web log panel**: lets you watch the same logfile live in a browser
- **Node helper**: a tiny client so JS skills can log without pain
Default logfile:
- `/tmp/jibo-skills.log`
Default ports:
- log daemon UDP: `15140`
- web panel HTTP: `15150`
## 1) Start services on the robot - if you used the installer they should be enabled automatically
### Log daemon (required)
Init.d:
- `/etc/init.d/jibo-skills-logd start`
- `/etc/init.d/jibo-skills-logd status`
### Web panel (optional)
Init.d:
- `/etc/init.d/jibo-skills-logpanel start`
- `/etc/init.d/jibo-skills-logpanel status`
Open in a browser:
- `http://<robot-ip>:15150/`
## 2) Send logs from programs
### Node.js (skills)
Use the helper:
- `const rlog = require('/opt/jibo/Jibo/Skills/@be/be/be/robot-logger');`
API:
- `rlog.info(tag, msg, data)`
- `rlog.warn(tag, msg, data)`
- `rlog.error(tag, msg, data)`
- `rlog.debug(tag, msg, data)`
- `rlog.raw(line)` (sends a plain text line)
Examples:
- `rlog.info('menu', 'opened main menu')`
- `rlog.error('radio', 'failed to tune', { station: freq, err: String(e) })`
One-liner test:
- `node -e "require('/opt/jibo/Jibo/Skills/@be/be/be/robot-logger').info('node-test','hello testajhdgjhasgjd',{x:1})"`
### Python 2.7
Send a JSON log packet via UDP:
- could be broken atm idk
- `python -c 'import socket, time; s=socket.socket(socket.AF_INET,socket.SOCK_DGRAM); s.sendto("{\\\"tag\\\":\\\"py-test\\\",\\\"level\\\":\\\"info\\\",\\\"msg\\\":\\\"hello\\\",\\\"data\\\":{\\\"t\\\":%d}}"%int(time.time()),("127.0.0.1",15140))'`
### Shell
This bypasses UDP and just appends to the logfile (still useful to verify the panel is tailing):
- `echo "RAW $(date) hello" >> /tmp/jibo-skills.log`
## 3) View logs
- `tail -f /tmp/jibo-skills.log`
## 4) Configuration (env vars)
Log daemon:
- `JIBO_LOGD_HOST` (default `127.0.0.1`)
- `JIBO_LOGD_PORT` (default `15140`)
- `JIBO_LOGD_FILE` (default `/tmp/jibo-skills.log`)
Web panel:
- `JIBO_LOGPANEL_BIND` (default `0.0.0.0`)
- `JIBO_LOGPANEL_PORT` (default `15150`)
Node client:
- `JIBO_LOGD_HOST` / `JIBO_LOGD_PORT`
## Notes
- If the web panel shows `(polling)`, thats ok it means the browser doesnt support SSE and its using `/tail` polling instead.
- If the panel is blank, confirm `/tail` works:
- `wget -qO- "http://127.0.0.1:15150/tail?pos=0&max=5000"`

View File

@@ -0,0 +1,80 @@
# Modularity ground-work (Skills FS)
Target: make the main menu + skill launching as modular as possible while staying compatible with the existing precompiled BE bundle.
## What you have today (important choke points)
### 1) BE is a big precompiled bundle
- The BE “brain” is shipped as [@be/be/index.js](@be/be/index.js) (UMD/browserify bundle).
- A number of runtime steps are already split into editable modules under [@be/be/be/](@be/be/be/):
- [@be/be/be/postinit.js](@be/be/be/postinit.js) is especially valuable because it runs after init and is easy to patch.
### 2) Menu is already semi-modular (via patch)
- The menu injection is wired in here: [@be/be/be/postinit.js](@be/be/be/postinit.js)
- The injection logic is here: [@be/be/menu/main-menu-patch.js](@be/be/menu/main-menu-patch.js)
- There are two alternate menu implementations that arent currently the default path:
- [@be/be/menu/skills-scanner.js](@be/be/menu/skills-scanner.js)
- [@be/be/menu/menu-manager.js](@be/be/menu/menu-manager.js)
### 3) Hard limit: “menu entry exists” ≠ “skill is loadable”
- BE loads skills listed in [@be/be/package.json](@be/be/package.json) → `jibo.skills[]`.
- So the current menu patch can *show* buttons for new skills, but launching them will fail unless BE can actually load them.
## What “max modular menu” realistically means
You can make the **menu definition** fully modular without touching the BE bundle. Making **skill loading** fully modular is harder because the bundle pre-bakes skill modules.
## Step-by-step path (least risky first)
### Phase A — Make menu config fully modular (low risk)
1. **Single source of truth for scanning**
- Stop duplicating scanner logic between `main-menu-patch.js` and `skills-scanner.js`.
- Export a stable `scanSkills(rootDir, options)` function that returns the exact menu schema expected by the main-menu skill.
2. **Make Skills root configurable**
- Replace hard-coded `/opt/jibo/Jibo/Skills` with:
- env var `JIBO_SKILLS_ROOT`, else
- fallback `/opt/jibo/Jibo/Skills`.
3. **Allow multiple menu providers (plugin style)**
- Add an entries directory, e.g. `/opt/jibo/Jibo/Skills/@be/menu-entries.d/`.
- Providers can be:
- `.js` files that export `getEntries()`
- `.json` static entries
- Patch merges provider entries + scanned `menuEntry.json` entries.
4. **Move icon/color policy into config**
- Keep defaults, but load overrides from a JSON config file (ex: `@be/be/menu/menu-config.json`).
### Phase B — Add Python-friendly menu generation (low/medium risk)
1. Implement a “generated menu file” contract:
- Python script writes a JSON file, e.g. `/tmp/jibo-menu.generated.json`.
- Menu patch reads it (if present) and merges into the menu.
2. This lets you write menu logic in Python 2.7 without fighting JS runtime quirks.
### Phase C — Make skill launching modular (hard)
This is the real blocker for “true modularity”. Options:
1. **Soft approach** (menu remains modular; launching only works for known skills)
- Accept that new entries are “shortcuts” to existing/registered skills.
2. **Medium approach** (dynamic skill registry)
- Maintain a small registry mapping `skillId -> folder path` by scanning `/opt/jibo/Jibo/Skills`.
- On launch, route through a single “launcher” skill (registered in BE) that loads content/assets from disk.
3. **Hard approach** (replace BE bundle / build chain)
- Rebuild BE so skills are not baked into the browserify bundle.
- This is the cleanest long-term solution but requires toolchain alignment and more invasive changes.
## Logging groundwork (so you can debug all of the above)
To make JS/Python logging painless on a BusyBox robot, the repo now includes a tiny UDP log daemon + init.d template:
- tools/robot/logd/jibo_logd.py
- tools/robot/init.d/jibo-skills-logd
This gives you a single log file you can tail on robot (default `/tmp/jibo-skills.log`) and a trivial Node client under:
- @be/be/be/robot-logger.js
Next step is to switch noisy modules (menu patch, loaders) to send logs to the daemon.

View File

@@ -1,9 +0,0 @@
{
"type": "submenu",
"title": "Test Submenu",
"submenuTitle": "Test Skills",
"icon": "resources/icons/fun-stuff.png",
"color": "purple",
"description": "A submenu containing additional test skills",
"order": 20
}

View File

@@ -1,9 +0,0 @@
{
"type": "skill",
"title": "Test Skill B",
"icon": "resources/icons/surprise.png",
"color": "green",
"skillId": "testSkillB",
"description": "A skill inside a submenu",
"order": 1
}

View File

@@ -0,0 +1,61 @@
const EventEmitter = require('events');
class LocalRadioPlayer extends EventEmitter {
constructor() {
super();
this._stations = {
'Rock': 'http://localhost:8000/rock',
'Pop': 'http://localhost:8000/pop',
'Jazz': 'http://localhost:8000/jazz',
'Classical': 'http://localhost:8000/classical',
'Electronic': 'http://localhost:8000/electronic',
'Hip-Hop': 'http://localhost:8000/hiphop'
};
this._audio = null;
}
getStations(options) {
return Promise.resolve(Object.keys(this._stations).map(genre => ({
name: genre,
id: genre
})));
}
play(stationData) {
if (this._audio) {
this._audio.pause();
}
const streamUrl = this._stations[stationData.id];
if (!streamUrl) {
this.emit('error', new Error(`Station ${stationData.id} not found.`));
return;
}
this._audio = new Audio(streamUrl);
this._audio.play()
.then(() => {
this.emit('song-data', {
title: `Streaming ${stationData.id}`,
artist: 'Local Radio',
albumArt: ''
});
})
.catch(err => {
this.emit('error', err);
});
}
stop() {
if (this._audio) {
this._audio.pause();
this._audio = null;
}
}
resizeArtwork(options) {
// Not applicable for local streaming without metadata
return Promise.resolve('');
}
}
module.exports = LocalRadioPlayer;

View File

@@ -1,2 +1,11 @@
let thisPackage = require('./package.json');
module.exports = thisPackage.version;
const LocalRadioPlayer = require('./LocalRadioPlayer');
/**
* @returns {RadioPlayer}
*
* i tried to make the radio work as well, coldnt get it to work :(
*/
module.exports = function createRadio() {
return new LocalRadioPlayer();
};

View File

@@ -1,9 +0,0 @@
{
"type": "skill",
"title": "Test Skill A",
"icon": "resources/icons/settings.png",
"color": "blue",
"skillId": "testSkillA",
"description": "A test skill to demonstrate menu integration",
"order": 10
}

View File

@@ -1,10 +0,0 @@
{
"type": "skill",
"title": "AAAAAAAAA",
"icon": "resources/icons/gallery.png",
"color": "blue",
"skillId": "testSkillA",
"description": "A test skill to demonstrate menu integration",
"order": 11
}

View File

@@ -0,0 +1,5 @@
^BG
/tools/robot/init.d/ -> /etc/init.d/
/tools/robot/logd/ -> /opt/jibo/Jibo/Skills/tools/robot/logd/
/tools/robot/logpanel/ -> /opt/jibo/Jibo/Skills/tools/robot/logpanel/
^ND

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:]))