Lightweight MQTT-to-HTTP bridge for smalltv-ultra devices. Loads YAML configuration, subscribes to an MQTT broker, and forwards certain device commands as HTTP GET requests.
Quick start:
- Install dependencies:
npm install - Run in development:
npm run dev -- config.yaml - Build:
npm run buildand run:npm run start - Test:
npm test
When running in a minimal Linux container (for example node:alpine), SVG text rendering can show 'tofu' (empty squares) if system fonts are not available. If you see squares where text should be when using the IMAGE/GENERATE feature inside Docker, ensure the runtime container includes fontconfig and at least one TrueType font like DejaVu.
For the official Docker image included in this repo, the runtime stage installs fontconfig and ttf-dejavu and runs fc-cache so Sharp can render text correctly. If you build your own image or use another base, ensure you install these packages and update font cache, for example:
apk add --no-cache fontconfig ttf-dejavu ttf-freefont
fc-cache -f -vIf you need additional language support (e.g., Chinese/Japanese/Korean), include a CJK font such as Noto CJK fonts in your image.
The YAML structure is shown in config.yaml. The repo accepts two forms for devices:
- Array form (existing style):
devices:
- name: lounge-tv
type: smalltv-ultra
host: 192.168.1.50- Mapping form (preferred):
devices:
lounge-tv:
type: smalltv-ultra
host: 192.168.1.50Notes:
- The
hostproperty accepts either an IP address or a hostname (DNS).
- This project supports verifying state after issuing a command, and a background poller that loads state on startup and periodically refreshes.
- The optional
verifysection inconfig.yamlcontrols these features (example inconfig.yaml):
verify:
afterCommand: true
retries: 3
initialDelayMs: 300
backoffMs: 200
pollIntervalSeconds: 30afterCommandenables automatic verification by readingbrt.jsonorapp.jsonafter setting values.pollIntervalSeconds(default 30s) configures background polling to refresh device state every N seconds. On startup the controller will fetch all device state once, then start polling.
On the initial connect and on every polling cycle the controller fetches the device state and republishes the retained MQTT state topics for the key values it knows about. Specifically it will publish (if present):
<basetopic>/<device>/BRIGHTNESS(0-100)<basetopic>/<device>/THEME(1-7)<basetopic>/<device>/COLONBLINK(YES/NO)<basetopic>/<device>/12HOUR(YES/NO)<basetopic>/<device>/DST(YES/NO)
This ensures that after connecting (or while polling) the retained MQTT topics reflect the device's current state.
The controller supports two patterns for sending commands:
-
Preferred: publish to
<basetopic>/<deviceName>/<ITEM>/SETwith the payload containing the value. Examples:mosquitto_pub -h 127.0.0.1 -t gm/lounge-tv/BRIGHTNESS/SET -m '75'mosquitto_pub -h 127.0.0.1 -t gm/lounge-tv/THEME/SET -m '3'mosquitto_pub -h 127.0.0.1 -t gm/lounge-tv/COMMAND -m 'REBOOT'mosquitto_pub -h 127.0.0.1 -t gm/lounge-tv/COLONBLINK/SET -m 'YES'mosquitto_pub -h 127.0.0.1 -t gm/lounge-tv/12HOUR/SET -m 'NO'mosquitto_pub -h 127.0.0.1 -t gm/lounge-tv/DST/SET -m 'YES'mosquitto_pub -h 127.0.0.1 -t gm/lounge-tv/IMAGE/SET -m '<data-uri-or-base64>'
The
device/<ITEM>/SETpayload accepts a plain value, e.g.75, or JSON like{"value":75}.
-- Legacy per-command topics have been removed. Only the device/<ITEM>/SET and device/COMMAND patterns are supported.
For boolean flags (COLONBLINK, 12HOUR, DST):
- Publishing: these state topics will be published as the strings
YESfor 1 andNOfor 0 to abstract the underlying numeric values. - Set (command): you can send a SET payload as any of the following and it will be normalized to 0/1 for the device:
YES/NO,1/0,true/false,ON/OFF.
IMAGE uploads
- Publish to
<basetopic>/<deviceName>/IMAGE/SETwith the payload set to a data URI (e.g.data:image/png;base64,...) or a raw base64 string of the image. - The controller will process the image and ensure the final uploaded image is exactly 240x240 pixels.
- If the incoming image is larger than 240x240, the per-device image config (see below) controls whether the image is cropped or resized.
- After a successful upload the controller will set
THEMEto3on the device automatically.
Supported upload formats and behavior
-
The device accepts JPG/JPEG and GIF uploads. If an input image is not in a supported format (for example, PNG), the controller will convert it to JPG before uploading.
-
If the input is a GIF and is already exactly 240x240, the controller will upload it as a GIF and preserve the GIF (no conversion). If the GIF is not the right size it will be converted to JPG.
-
If the input is a GIF and is already exactly 240x240, the controller will upload it as a GIF and preserve the GIF (no conversion). If the GIF is not 240x240 the controller will resize/crop it to 240x240 and upload an animated GIF (animation is preserved when possible).
-
Upload endpoint: the controller uploads images via multipart/form-data to
http://<device.host>/doUpload?dir=/image/using a single form field namedimage. -
The uploaded filename is always
upload.<ext>(for exampleupload.jpgorupload.gif) so that the device can overwrite the same file each time and conserve storage. -
After setting the theme to
3the controller also instructs the device to select the uploaded file by calling:http://<device.host>/set?img=%2Fimage%2F%2Fupload.<ext>where
<ext>is the actual file extension used (jpg or gif).
Device image configuration (per-device)
Add an image block under each device in config.yaml to control oversize behaviour and crop position. Example:
devices:
lounge-tv:
type: smalltv-ultra
host: 192.168.1.50
image:
oversize: crop # crop | resize (default: resize)
cropposition: topright # top|left|bottom|right|topleft|topright|bottomleft|bottomright|center (default: center)
flip:
vertical: false # flip image vertically before upload
horizontal: false # flip image horizontally before upload
rotate: 0 # rotate degrees: 0, 90, 180, 270 (default 0)When oversize: crop the controller will extract a 240x240 section from the incoming image based on cropposition (for example topright will select the 240x240 square from the top-right corner). When oversize: resize the controller will scale the image to fit within a 240x240 box and pad as needed to produce an exact 240x240 final image.
The flip and rotate options apply image transforms before the image is uploaded to the device. They affect both images uploaded directly via IMAGE/SET and images generated via IMAGE/GENERATE. For GIFs, animation is preserved when possible by the Sharp pipeline.
Note on device upload quirks:
- Some device firmwares return an HTTP error message like "Duplicate content length" even though the upload actually succeeds. The controller treats that specific error as a successful upload and proceeds to select the image and set the theme.
Compatibility note for selecting uploaded images
- The controller tries multiple variants when instructing the device to select the uploaded image (for example encoded vs unencoded paths,
/image//upload.jpgvs/image/upload.jpg, etc). By default the controller now attempts to select the image first and then setTHEME=3(this order works better on most devices). If that doesn't succeed it falls back to tryingTHEME=3first then selecting the image. If needed you can tune the small delays and retry counts per-device using theimage.selection*options shown above.
You can programmatically generate a 240×240 image and upload it to the device by publishing to the topic:
<basetopic>/<deviceName>/IMAGE/GENERATE
Payloads supported:
- Plain string: treated as the text to render.
- JSON object:
{ "text": "...", "background": "#000000", "textColor": "#ffffff", "fontSize": 28, "halign": "left|center|right", "valign": "top|center|bottom", "hmargin": 0, "vmargin": 0 }(all fields optional) - JSON array: An array of the JSON objects above. The order of the array controls z-order (first is bottom-most, last is top-most). This allows rendering multiple pieces of text or overlapping inline images in specified positions.
Markup supported in the text string:
-
[color=#rrggbb]...[/color]— set a hex color for the enclosed text (eg.#ff0000). -
[b]...[/b]— bold text. -
[i]...[/i]— italic text. -
[img:data-uri]— inline image using a data URI (for exampledata:image/png;base64,...). Inline images are centered and rendered at ~96×96 by default. Optional size suffix: you can specify|WxHor|Wafter the data URI to control inline render size (pixels). Examples:[img:data:image/png;base64,...|48]— renders inline image at 48×48.[img:data:image/png;base64,...|96x32]— renders inline image at 96×32. -
Use
\n(or real line breaks in JSON strings) to create new lines. -
halign- Horizontal alignment for text and inline images (defaultcenter). Values:left,center,right. -
valign- Vertical alignment within the 240×240 image (defaultcenter). Values:top,center,bottom. -
hmargin- Optional integer (pixels) specifying the horizontal margin relative to the chosenhalignanchor. Whenhalign=left, this is the number of pixels from the left edge; whenhalign=right, this is the number of pixels from the right edge; whenhalign=center, this is a pixel offset from the image center (positive moves right). -
vmargin- Optional integer (pixels) specifying the vertical margin relative to the chosenvalignanchor. Whenvalign=top, this is the number of pixels from the top edge; whenvalign=bottom, this is the number of pixels from the bottom edge; whenvalign=center, this is a pixel offset from the vertical center (positive moves down). -
Multiple spaces are preserved in generated text (the renderer sets xml:space="preserve" so
A Bkeeps two spaces).
Behavior and defaults:
- Final image is rendered to 240×240 pixels and uploaded as
upload.jpgtohttp://<device>/doUpload?dir=/image/. - The controller sets the device
THEMEto3and issues aset?img=...to select the uploaded image. - Default background:
#000000(black). Default text color:#ffffff(white). Default font size:28(the renderer will shrink the font to fit if necessary down to a small minimum). - Only a simple markup language is supported (no HTML/CSS); nesting is supported in simple cases (bold/italic inside color blocks), but complex nesting or layout is not guaranteed.
Progress/status via MQTT:
-
While generating, uploading, and selecting images the controller publishes status updates to the retained topic:
<basetopic>/<deviceName>/IMAGE/STATUSEach message is a small JSON string containing a
stagefield. Common stages emitted arerendering,uploading,uploaded,selecting,done, anderror. Thedonepayload will includethemeOkandimgSelectedbooleans and may includethemeUrlandimgUrl(the exactsetURLs that succeeded) to aid remote debugging.
Examples (Unix shell):
mosquitto_pub -h 127.0.0.1 -t gm/lounge-tv/IMAGE/GENERATE -m 'Hello World'
mosquitto_pub -h 127.0.0.1 -t gm/lounge-tv/IMAGE/GENERATE -m '{"text":"Line1\n[color=#ff0000][b]Red[/b][/color]" , "background":"#000000", "textColor":"#ffffff", "fontSize":28}'Examples (PowerShell):
mosquitto_pub -h 127.0.0.1 -t gm/lounge-tv/IMAGE/GENERATE -m '{"text":"Line1\n[img:data:image/png;base64,....]"}'Example with left/top alignment:
mosquitto_pub -h 127.0.0.1 -t gm/lounge-tv/IMAGE/GENERATE -m '{"text":"Left aligned", "halign":"left", "valign":"top"}'Example with left/top alignment and margins:
mosquitto_pub -h 127.0.0.1 -t gm/lounge-tv/IMAGE/GENERATE -m '{"text":"Left aligned", "halign":"left", "valign":"top", "hmargin":12, "vmargin":8 }'Multiple layers example (z-order: first bottom-most, last top-most):
mosquitto_pub -h 127.0.0.1 -t gm/lounge-tv/IMAGE/GENERATE -m '[{"text":"Background", "background":"#000000"}, {"text":"Top text", "textColor":"#00ff00"}]'Example with center alignment and offsets:
mosquitto_pub -h 127.0.0.1 -t gm/lounge-tv/IMAGE/GENERATE -m '{"text":"Centered with offset", "halign":"center", "valign":"center", "hmargin":10, "vmargin":15 }'Tip: if sending large data URIs inside MQTT messages, consider using a JSON payload and single-quoting the whole message on shells that support it so you don't need to escape inner double quotes.
The controller publishes a retained LWT status message to <basetopic>/STATUS (default gm/STATUS). The message is:
ONLINE(retained) — published by the controller when it successfully connects to the broker.OFFLINE(retained) — published by the broker if the controller disconnects unexpectedly (LWT), and also published by the controller on graceful shutdown.
This topic is useful for monitoring and for tools and automations which need to know whether the controller is currently connected to the MQTT broker.
See src/deviceController.ts → generateAndUploadImage for the exact implementation details if you need to understand parsing/limitations.
State topics are read-only. Sending commands is only supported on the SET subtopic, e.g. gm/<device>/BRIGHTNESS/SET or gm/<device>/THEME/SET.
Build a production image:
docker build -t gm-controller:latest .Run with a mapped config folder (recommended):
docker run --rm -v /path/to/config:/config gm-controller:latestWhen the container starts it will check /config/config.yaml (this is the default argument). Map your host folder containing the file into /config in the container so it can be configured at runtime. If you mount a config folder, ensure config.yaml exists in that host folder.
The YAML structure is shown in config.yaml.
For secure deployments, you should avoid embedding credentials in config.yaml when possible. The controller supports two environment-based ways to provide the MQTT password:
MQTT_PASSWORD_FILE— the path to a file that contains the MQTT password. This is useful for Docker secrets or mounted files. If the file does not exist the application will fail to start with an explicit error.MQTT_PASSWORD— a plain environment variable containing the MQTT password.
Precedence (higher to lower): MQTT_PASSWORD_FILE > MQTT_PASSWORD > the mqtt.password field in config.yaml.
Examples:
Use an environment variable:
docker run --rm -v /path/to/config:/config -e MQTT_PASSWORD=super-secret gm-controller:latestUse a secret file (bind-mounted or Docker secret):
docker run --rm \
-v /path/to/config:/config \
-v /path/to/mqtt_password:/run/secrets/mqtt_password \
-e MQTT_PASSWORD_FILE=/run/secrets/mqtt_password \
gm-controller:latestWith Docker Compose (example):
version: '3.7'
services:
gm-controller:
image: gm-controller:latest
volumes:
- ./config:/config
secrets:
- mqtt_password
environment:
- MQTT_PASSWORD_FILE=/run/secrets/mqtt_password
secrets:
mqtt_password:
file: ./mqtt_passwordNote: MQTT_PASSWORD_FILE is preferred for security reasons since the file contents are not visible in process environment or Docker inspect output.
Vibe coded by Raptor mini (Preview), with human "assistance".