Docs / LemmeSee

Writing a LemmeSee plugin

A plugin is one Python file in the hub's plugins directory (config.json → plugins_path, by default LemmeSeeScripts/plugins/). The file name is the plugin's permanent id: it names its permissions (plugins.<tier>.<name>), its URLs (/plugins/<name>/) and its installs on phones, so pick it once. Use lowercase letters, digits and underscores, and don't start it with _.

A plugin can keep more code and data in a folder with the same name next to the file (hello.py + hello/); the folder is importable as a package.

Plugins run inside the hub process with the hub's access. Only install plugins you trust.

A minimal plugin

# hello.py
PLUGIN_NAME        = "Hello"
PLUGIN_DESCRIPTION = "Says hello."
PLUGIN_PAGES       = [{"title": "Hello", "url": "/plugins/hello/", "monitor": True}]

from fastapi.responses import HTMLResponse
from lemmesee import plugin

hub = plugin.Plugin("hello")


async def page():
    return HTMLResponse("<h1>Hello</h1>")


async def start(task_id, cfg):
    hub.route("GET", "/", page)
    news = hub.feed("hello", name="Hello")
    news.push([{"id": "1", "title": "Hi there", "ts": plugin.now_ms()}])
    hub.state("status", label="Status", value="running")
    hub.log("info", "hello started")


async def stop(task_id):
    hub.close()

To run it, put the file in the plugins directory, then on the hub's Tasks page add a task of type Plugin and pick it. The task's settings are passed to start as cfg. Starting, stopping and editing the task calls start and stop again; editing the plugin file reloads it on the next start.

Settings at the top of the file

The hub reads these without running the file, so they must be plain values placed before the first import, def or class line.

SettingMeaning
PLUGIN_NAMEDisplay name. A task's display_name overrides it when one task uses the plugin.
PLUGIN_DESCRIPTIONOne line shown in the task picker and on phones.
PLUGIN_PRIVATETrue: users need a plugins.private.<name> grant to see the plugin at all. Otherwise everyone sees it unless plugins.public.<name> is revoked from them.
PLUGIN_PAGESPages to list on phones: {"title", "url", "monitor", "icon_url", "description", "access"}. monitor: True puts the page in the Monitor dropdown. See Pages.
PLUGIN_FEED_PAGES{feed_id: url}: show that feed as your page instead of the app's list.
PLUGIN_FEED_LABELTitle for feeds shown through your page, e.g. "Hello: {feed}".
PLUGIN_DASHBOARD_TABTrue: your states get their own tab on the server's Dashboard item.
PLUGIN_URLURL prefix your routes live under when it isn't /plugins/<name>. plugin_runner.mount refuses prefixes outside it.
PLUGIN_BRIDGETrue: your pages may use the app's LkBridge.
PLUGIN_PATHSExtra hub paths your pages load (e.g. ["/images/hello/"]).
PLUGIN_USESOther plugins whose APIs your pages and plugin keys call.
PLUGIN_NETWORKOutside hosts your pages and scripts may reach through the bridge, e.g. ["api.example.com"]; ["*"] for any.
PLUGIN_KEY_PREFIXPrefix for the keys your pages store on the phone, e.g. "Hello_". Without one your pages can't use keys or secrets.
PLUGIN_ALLOW_COOKIESYour pages' browser keeps cookies, including third-party ones.
PLUGIN_ALLOW_POPUPSYour pages may open pop-up windows. With cookies, links stay inside the app.
PLUGIN_SETTINGSThe task settings form. See Task settings.

A plugin is listed to phones only while it has an enabled task.

Lifecycle

FunctionRequiredWhen
async start(task_id, cfg)yesThe task starts. Start your work with asyncio.create_task(...) and return; the hub waits at most 60 s.
async stop(task_id)yesThe task stops. Cancel your work and call hub.close().
stop_all()noHub shutdown, once per plugin.
get_pages()noReturns pages computed at run time; replaces PLUGIN_PAGES while the plugin is loaded. Call mqtt.signal_plugins_dirty() when the list changes.
diagnostics()noReturns a dict shown on the hub's Diagnostics page.

Each task gets its own thread and event loop. Several tasks can use the same plugin file; they share the module, so keep per-task data in a dict keyed by task_id.

HTTP handlers run on the hub's own event loop, not your task's. Keep them fast and push slow work to your task.

A plugin that crashes 10 times within 10 minutes is left stopped and listed on the Diagnostics page; starting the task again clears it.

The lemmesee.plugin API

hub = plugin.Plugin("<name>") registers everything on behalf of your plugin and removes it all on hub.close().

CallDoes
hub.route(method, path, handler, public=False, feature=None)Serves handler at /plugins/<name><path>. Signed-in users only unless public=True; feature="key" requires that permission.
hub.feed(id, name=, private=, description=, icon_url=, also_published_by=)Registers a feed you own and returns it. private=True feeds need a feeds.private.<id> grant.
feed.push(items)Appends events. Each item needs id and ts (milliseconds).
feed.set(items)Replaces the feed's current state, keyed by id.
feed.push_to_user(user_id, items)Items only that user sees.
feed.clear()Empties the feed.
hub.state(name, label=, value=, display="text", alarm=False, interval=0, **extra)Publishes a state. display is one of text, key_value, table, pie, donut, bar_chart, gauge, status_row, sparkline, big_number, progress, countdown, list, timeline, heatmap, image, composite, link.
hub.clear_state(name)Removes a state.
hub.log(level, message, public=False)Logs under your plugin's name: debug, info, important, warning or error.
hub.topics(patterns)Declares MQTT topics you publish outside your feeds (lemmesee/+/feed/public/hello/#). Readers need access to your plugin.
hub.close()Removes your routes, feeds and states.
plugin.now_ms()Current time in milliseconds.

Anything beyond this (core.*, modules.*) is hub internals and can change.

Permissions

The hub and the MQTT broker check the same keys.

WhatKeyDefault
Your pluginplugins.<public|private>.<name>follows PLUGIN_PRIVATE
A feedfeeds.<public|private>.<id>follows the feed's private
A statestates.<name>.<state> (or states.<name>.*)needs a grant
A page that shows one record<kind>.<public|private>.<id> from its accessfollows access.private

A request for something a user can't see answers exactly like a request for something that doesn't exist (404), so nothing reveals what exists. Users who can't see your plugin get that 404 from the hub for every route under your paths. For records inside your plugin, check with core.users.can_see(kind, id, private, user) and answer JSONResponse({"detail": "Not Found"}, status_code=404), the same body an unknown route gets.

Pages on the phone

Pages are hub URLs the LemmeKnow app opens in its browser view. The app adds the user's access key to page loads and bridge requests only for your plugin's paths: /plugins/<name>/, /api/<name>/, PLUGIN_URL, PLUGIN_PATHS, the APIs of plugins in PLUGIN_USES, and the shared /api/feeds/, /api/feed/ and /api/users/me.

When a page loads with the access key, the hub also sets a session cookie for your plugin alone (ls_page_<name>), sent only to /plugins/<name>/, /api/<name>/ and PLUGIN_URL and honored only there. With PLUGIN_ALLOW_COOKIES the page's own forms and fetch calls to those paths authenticate with it. Without it the page has no cookies, so send writes through the bridge's hubPost. The hub web UI's session and other plugins' page sessions are never sent from your pages in the app.

A page that shows one record (a site, a camera, a document) declares it:

{"title": "Plex", "url": "/gates/plex/", "monitor": True,
 "access": {"kind": "sites", "id": "plex", "private": False}}

The hub lists it only to users who pass can_see("sites", "plex", False, user) and drops access from what it sends.

With PLUGIN_BRIDGE = True your pages get window.LkBridge: storage, keys under PLUGIN_KEY_PREFIX, rules, hub requests limited to the paths above, and rawFetchAsync / rawPostAsync to PLUGIN_NETWORK hosts. Frames inside your page don't get the bridge; talk to it from your top-level page. The full method list is in LemmeKnow's doc/SPEC.md.

A page can install itself on the phone (Premium) with LkBridge.installPluginWithExecAndContent(...); the user reviews every file first, including the hosts and plugins it may reach. Serve the files it names under /plugins/<name>/, including icon.png.

An installed plugin can bring a page that runs from the phone with no hub connection, an exec.js action that rules run, and content: delivery screens, sounds, textures, fonts and themes. The action script gets Lk.hubGet(path) / Lk.hubPost(path, body) on the same paths as your pages, sent to the server it was installed from.

A complete example

demo.py with its demo/ folder in the plugins directory uses all of this: a private feed shown through the plugin's own page (PLUGIN_FEED_PAGES), an install button on that page, an installed control page that works offline, an action that posts to the plugin's /send route (which calls the webhook set in the task settings, keeping its secret on the hub), and a delivery screen with a sound, two textures and a theme. Its install.json route shows how the hashes the phone checks are built.

Task settings

PLUGIN_SETTINGS builds the form on the task editor and checks values when the task is saved:

PLUGIN_SETTINGS = [
    {"key": "city",     "label": "City",             "default": "Portland", "required": True},
    {"key": "interval", "label": "Check every (s)",  "default": 300},
    {"key": "metric",   "label": "Use metric units", "default": True},
    {"key": "api_key",  "label": "API key",          "type": "secret"},
    {"key": "source",   "label": "Source",           "options": ["noaa", "metno"], "default": "noaa"},
]

type is text, number, bool, secret or select, inferred from default (or options) when left out. Numbers and booleans arrive converted. A secret field never shows its stored value, and leaving it blank keeps it. Keys you don't declare can still be set in the editor's JSON box.

Testing

  • Your plugin appears in the Tasks page's script picker as soon as the file is in the plugins directory.
  • The hub's Logs page shows your hub.log lines; its Diagnostics page shows your diagnostics() output, plugins that stopped after repeated crashes, and MQTT topics with no access rule.
  • ?sim=<username> on any hub page shows the hub as that user (read only), which is the quickest way to check who sees what.