I'm working on some tools to make the page creation easier.
To help where there is essentially a lot of duplication, eg X number of buttons on X number of pages, where each button only differs by a label or icon etc, I'm trying to create a kind of templating system so the actual amount of typing to produce pages is a minimal as possible.
I hope this will eventually lead to something where only a list of labels / icons is needed to create a page of buttons etc.
It's still very much work in progress but if anyone is interested in giving it a go here is the simple flow (much less simple function node
)
You can inject required parameters -
{"screenWidth":480,"screenHeight":307,"numRows":6,"numCols":7,"gapBetweenItems":5,"topMargin":20,"bottomMargin":0,"leftMargin":0,"rightMargin":0}
Seems to work OK from 1x1 to 7x7, if you can read the text at 7x7 you have better eyesight than me ![]()
Screen size is the size of the container obj. Margins are optional, but use 4 values in the correct order if you do provide them. My example is to fit x number of items into a tab using the tabview element, and as I wanted some space at the top to press the tab buttons, I added the margin option.
The test layout is created automatically, based on the desired grid. The size of the buttons, fonts and icons are calculated along with the x and y coordinates of each button, these are dynamically added to a standard button template which contains all the formatting so there is minimal editing required.
Just for good measure it pulls in a background colour from an array based on the id of the button ![]()
Topics, broker and page number etc will need to be setup for your system in the MQTT node and at the bottom of the function node - msg.topic = "hasp/1/command" msg.payload = ['clearpage 3']


[{"id":"b8db925542b1e4d8","type":"group","z":"fb8e012451d1e803","name":"Layout testing","style":{"fill":"#ffbfbf","label":true},"nodes":["1a89d406f2e9c9ca","c9f0b4285251c548","8906e4034994d2a9","e79e533707f96908","ba66805bb61a8530"],"x":704,"y":49,"w":902,"h":142},{"id":"1a89d406f2e9c9ca","type":"inject","z":"fb8e012451d1e803","g":"b8db925542b1e4d8","name":"","props":[{"p":"payload"},{"p":"topic","vt":"str"}],"repeat":"","crontab":"","once":false,"onceDelay":0.1,"topic":"","payload":"{\"screenWidth\":480,\"screenHeight\":308,\"numRows\":6,\"numCols\":7,\"gapBetweenItems\":5,\"topMargin\":20,\"bottomMargin\":0,\"leftMargin\":0,\"rightMargin\":0}","payloadType":"json","x":800,"y":135,"wires":[["c9f0b4285251c548"]]},{"id":"c9f0b4285251c548","type":"function","z":"fb8e012451d1e803","g":"b8db925542b1e4d8","name":"Scene buttons layout tool","func":"const grid = global.get(\"calculateGridValues\")\nnode.warn(msg.payload);\nconst layout = grid(\n msg.payload.screenWidth,\n msg.payload.screenHeight,\n msg.payload.numRows,\n msg.payload.numCols,\n msg.payload.gapBetweenItems,\n msg.payload.topMargin,\n msg.payload.bottomMargin,\n msg.payload.leftMargin,\n msg.payload.rightMargin\n);\nnode.warn(layout);\n\nlet numIcons = msg.payload.numRows * msg.payload.numCols\nconst scale = Math.min(layout.itemWidth / 150, layout.itemHeight / 130); // original button was 150 x 130\n\nconst textColor = \"#bfbfbf\"\nconst backgroundColor = \"#000000\"\nconst btnColors = [\"#FFFFFF\", \"#b59f2f\", \"#F26462\", \"#0B7C98\", \"#254F3B\", \"#76AA4B\", \"#742a75\", \"#94742e\", \"#823532\", \"#F6963B\", \"#D74548\", \"#a93fb5\",\n \"#AFAE6E\", \"#0e7d87\", \"#0C4976\", \"#1d1f99\", \"#519190\", \"#aba28e\", \"#d47a35\", \"#FFFFFF\", \"#b59f2f\", \"#F26462\", \"#0B7C98\", \"#254F3B\", \"#76AA4B\", \"#742a75\", \"#94742e\", \"#823532\", \"#F6963B\", \"#D74548\", \"#a93fb5\",\n \"#AFAE6E\", \"#0e7d87\", \"#0C4976\", \"#1d1f99\", \"#519190\", \"#aba28e\", \"#d47a35\"]\n\n\nlet btnValues = {\n \"obj\": \"btn\",\n \"parentid\": 51, // first tab\n \"w\": layout.itemWidth,\n \"h\": layout.itemHeight,\n \"outline_width\": 1,\n \"outline_color\": \"#aaaaaa\",\n \"outline_opa\": 50,\n \"bg_color\": \"#356E62\",\n // \"bg_opa\": 255,\n // \"border_opa\": 255,\n // \"border_side\": 1,\n // \"border_width\": 11,\n // \"border_color\": \"#393939\",\n \"radius\": 8,\n \"text_font\": 58 * scale,\n \"text_color\": textColor,\n \"text_line_space\": -20 * scale,\n \"value_font\": 30 * scale,\n \"value_ofs_y\": 26 * scale,\n \"value_color\": textColor\n};\n\nconst tabviewValues = {\n \"id\": 50, \"obj\": \"tabview\", \"btn_pos\": 1, \"y\": 75, \"w\": 480, \"h\": 353, \"bg_color\": backgroundColor, \"text_font\": 26, \"border_side\": 0,\n \"bg_opa10\": 0, // 10 hide underscore showing selected tab\n \"bg_color30\": backgroundColor, // 30 tab name background\n \"text_color40\": \"#8a8a8a\", // 40 tab name font colour\n \"pad_top40\": 1, \"pad_bottom40\": 1 // 40 container for tab names\n\n}\nconst tabValues = { \"obj\": \"tab\", \"parentid\": 50 }\n\n// set x and y for each button based on id sequence ie 1 = top left\n// x & y are calculated by calculateGridValues function\nfunction calcXY(id) {\n const x = layout.valuesX[(id - 1) % layout.valuesX.length];\n const y = layout.valuesY[Math.floor((id - 1) / layout.valuesX.length) % layout.valuesY.length];\n return { x, y };\n}\n\nlet message = [\n\n { \"id\": 1, \"text\": \"\\uF1E1\\n\", \"value_str\": \"Main\", ...btnValues }, //change \"text\" value to icon and \"value_str\" to label\n { \"id\": 2, \"text\": \"\\uE8DD\\n\", \"value_str\": \"Lamp\", ...btnValues },\n { \"id\": 3, \"text\": \"\\uE91C\\n\", \"value_str\": \"Wall\", ...btnValues },\n { \"id\": 4, \"text\": \"\\uE6B5\\n\", \"value_str\": \"Dining\", ...btnValues },\n { \"id\": 5, \"text\": \"\\uE238\\n\", \"value_str\": \"Fire\", ...btnValues },\n { \"id\": 6, \"text\": \"\\uE1D9\\n\", \"value_str\": \"Fish Pump\", ...btnValues },\n { \"id\": 7, \"text\": \"\\uF1E1\\n\", \"value_str\": \"Blinds\", ...btnValues },\n { \"id\": 8, \"text\": \"\\uE8DD\\n\", \"value_str\": \"Open\", ...btnValues },\n { \"id\": 9, \"text\": \"\\uE91C\\n\", \"value_str\": \"Close\", ...btnValues },\n { \"id\": 10, \"text\": \"\\uE6B5\\n\", \"value_str\": \"Dining\", ...btnValues },\n { \"id\": 11, \"text\": \"\\uE238\\n\", \"value_str\": \"Fire\", ...btnValues },\n { \"id\": 12, \"text\": \"\\uE1D9\\n\", \"value_str\": \"Fish Pump\", ...btnValues },\n { \"id\": 13, \"text\": \"\\uF1E1\\n\", \"value_str\": \"Blinds\", ...btnValues },\n { \"id\": 14, \"text\": \"\\uE8DD\\n\", \"value_str\": \"Open\", ...btnValues },\n { \"id\": 15, \"text\": \"\\uE91C\\n\", \"value_str\": \"Close\", ...btnValues },\n { \"id\": 16, \"text\": \"\\uE6B5\\n\", \"value_str\": \"Dining\", ...btnValues },\n { \"id\": 17, \"text\": \"\\uE238\\n\", \"value_str\": \"Fire\", ...btnValues },\n { \"id\": 18, \"text\": \"\\uE1D9\\n\", \"value_str\": \"Fish Pump\", ...btnValues },\n { \"id\": 19, \"text\": \"\\uF1E1\\n\", \"value_str\": \"Blinds\", ...btnValues },\n { \"id\": 20, \"text\": \"\\uE8DD\\n\", \"value_str\": \"Open\", ...btnValues },\n { \"id\": 21, \"text\": \"\\uE91C\\n\", \"value_str\": \"Close\", ...btnValues },\n { \"id\": 22, \"text\": \"\\uE6B5\\n\", \"value_str\": \"Dining\", ...btnValues },\n { \"id\": 23, \"text\": \"\\uE238\\n\", \"value_str\": \"Fire\", ...btnValues },\n { \"id\": 24, \"text\": \"\\uE1D9\\n\", \"value_str\": \"Fish Pump\", ...btnValues },\n { \"id\": 25, \"text\": \"\\uE91C\\n\", \"value_str\": \"Close\", ...btnValues },\n { \"id\": 26, \"text\": \"\\uE6B5\\n\", \"value_str\": \"Dining\", ...btnValues },\n { \"id\": 27, \"text\": \"\\uE238\\n\", \"value_str\": \"Fire\", ...btnValues },\n { \"id\": 28, \"text\": \"\\uE1D9\\n\", \"value_str\": \"Fish Pump\", ...btnValues },\n { \"id\": 29, \"text\": \"\\uF1E1\\n\", \"value_str\": \"Blinds\", ...btnValues },\n { \"id\": 30, \"text\": \"\\uE8DD\\n\", \"value_str\": \"Open\", ...btnValues },\n { \"id\": 31, \"text\": \"\\uE91C\\n\", \"value_str\": \"Close\", ...btnValues },\n { \"id\": 32, \"text\": \"\\uE6B5\\n\", \"value_str\": \"Dining\", ...btnValues },\n { \"id\": 33, \"text\": \"\\uE238\\n\", \"value_str\": \"Fire\", ...btnValues },\n { \"id\": 34, \"text\": \"\\uE1D9\\n\", \"value_str\": \"Fish Pump\", ...btnValues },\n { \"id\": 35, \"text\": \"\\uF1E1\\n\", \"value_str\": \"Blinds\", ...btnValues },\n { \"id\": 36, \"text\": \"\\uE8DD\\n\", \"value_str\": \"Open\", ...btnValues },\n { \"id\": 37, \"text\": \"\\uE91C\\n\", \"value_str\": \"Close\", ...btnValues },\n { \"id\": 38, \"text\": \"\\uE6B5\\n\", \"value_str\": \"Dining\", ...btnValues },\n { \"id\": 39, \"text\": \"\\uE238\\n\", \"value_str\": \"Fire\", ...btnValues },\n { \"id\": 40, \"text\": \"\\uE1D9\\n\", \"value_str\": \"Fish Pump\", ...btnValues },\n { \"id\": 41, \"text\": \"\\uE91C\\n\", \"value_str\": \"Close\", ...btnValues },\n { \"id\": 42, \"text\": \"\\uE1D9\\n\", \"value_str\": \"Fish Pump\", ...btnValues },\n]\n\nlet buttons = []\nmessage.forEach(obj => {\n if (obj.id >= 1 && obj.id <= numIcons) {\n const { x, y } = calcXY(obj.id);\n obj.x = x;\n obj.y = y;\n obj.bg_color = btnColors[obj.id]\n buttons.push(obj)\n }\n});\n\nmsg.topic = \"hasp/1/command\"\nmsg.payload = ['clearpage 3']\nnode.send([null, msg]);\n\nmsg.payload = [{ \"page\": 3 }, { ...tabviewValues }, { \"id\": 51, \"text\": \"Lounge\", ...tabValues }, { \"id\": 52, \"text\": \"Scene\", ...tabValues },\n{ \"id\": 53, \"text\": \"Blinds\", ...tabValues }, ...buttons]\n\nreturn msg;","outputs":2,"timeout":0,"noerr":0,"initialize":"function calculateGridValues(width, height, numRows, numCols, minGap, topMargin = 0, bottomMargin = 0, leftMargin = 0, rightMargin = 0) {\n let minGapX = minGap;\n let minGapY = minGap;\n\n let remainingWidth, remainingHeight, itemWidth, itemHeight, availableWidth, availableHeight, totalHorizontalGap, totalVerticalGap;\n\n for (let index = minGap - 3; index < minGap + 3; index++) {\n totalHorizontalGap = (numCols - 1) * minGapX;\n totalVerticalGap = (numRows - 1) * minGapY;\n\n availableWidth = width - leftMargin - rightMargin - totalHorizontalGap;\n availableHeight = height - topMargin - bottomMargin - totalVerticalGap;\n\n itemWidth = Math.floor(availableWidth / numCols);\n itemHeight = Math.floor(availableHeight / numRows);\n\n remainingWidth = width - leftMargin - rightMargin - (itemWidth * numCols) - totalHorizontalGap;\n remainingHeight = height - topMargin - bottomMargin - (itemHeight * numRows) - totalVerticalGap;\n\n // If remaining space is not zero, try another minGap\n minGapX = remainingWidth !== 0 ? index : minGapX;\n minGapY = remainingHeight !== 0 ? index : minGapY;\n if (remainingWidth === 0 && remainingHeight === 0) break;\n }\n\n const valuesX = [];\n for (let col = 0; col < numCols; col++) {\n const x = leftMargin + col * (itemWidth + minGapX);\n valuesX.push(x);\n }\n const valuesY = [];\n for (let row = 0; row < numRows; row++) {\n const y = topMargin + row * (itemHeight + minGapY);\n valuesY.push(y);\n }\n\n return {\n valuesX,\n valuesY,\n itemWidth,\n itemHeight,\n availableWidth,\n availableHeight,\n remainingWidth,\n remainingHeight,\n totalHorizontalGap,\n totalVerticalGap,\n minGapX,\n minGapY\n };\n}\n\nglobal.set(\"calculateGridValues\", calculateGridValues);\n\n","finalize":"","libs":[],"x":995,"y":135,"wires":[["8906e4034994d2a9"],["ba66805bb61a8530"]]},{"id":"8906e4034994d2a9","type":"delay","z":"fb8e012451d1e803","g":"b8db925542b1e4d8","name":"","pauseType":"delay","timeout":"1","timeoutUnits":"seconds","rate":"1","nbRateUnits":"1","rateUnits":"second","randomFirst":"1","randomLast":"5","randomUnits":"seconds","drop":false,"allowrate":false,"outputs":1,"x":1195,"y":90,"wires":[["e79e533707f96908"]]},{"id":"e79e533707f96908","type":"function","z":"fb8e012451d1e803","g":"b8db925542b1e4d8","name":"Send jsonl Chunks","func":"// Limit msg.payloads to 2048 bytes to fit with OpenHasp buffer size\n\nlet message = msg.payload;\nlet topicParts = msg.topic.split(\"/\");\nlet topic = `hasp/${topicParts[1]}/command/jsonl`;\n\n// send in chunks\nconst maxLength = 2000;\nlet payload = \"\";\n\nfor (let index = 0; index < message.length; index++) {\n const jsonString = JSON.stringify(message[index]) \n\n if (payload.length + jsonString.length <= maxLength) {\n payload += jsonString;\n } else {\n sendPayload(topic, payload);\n payload = jsonString;\n }\n}\n\n// send any remaining payload\nsendPayload(topic, payload);\n\nfunction sendPayload(topic, payload) {\n if (payload.length > 0) {\n node.send({\n \"topic\": topic,\n \"payload\": payload\n });\n }\n}\n\n","outputs":1,"timeout":0,"noerr":0,"initialize":"","finalize":"","libs":[],"x":1360,"y":90,"wires":[["ba66805bb61a8530"]],"info":"let message=msg.payload\r\nlet topicParts = msg.topic.split(\"/\")\r\nlet topic = `hasp/${topicParts[1]}/command/jsonl`\r\n\r\n// send in chunks\r\nlet payload = \"\";\r\nconst maxLength = 2048;\r\nfor (let index = 0; index < message.length; index++) {\r\n const jsonString = JSON.stringify(message[index]) + \"\\n\";\r\n \r\n if (payload.length + jsonString.length <= maxLength) {\r\n payload += jsonString;\r\n } else {\r\n node.send({\r\n \"topic\": topic,\r\n \"payload\": payload\r\n });\r\n payload = jsonString;\r\n }\r\n}\r\n// anything remaining to send?\r\nif (payload.length > 0) {\r\n node.send({\r\n \"topic\": topic,\r\n \"payload\": payload\r\n });\r\n}"},{"id":"ba66805bb61a8530","type":"mqtt out","z":"fb8e012451d1e803","g":"b8db925542b1e4d8","name":"","topic":"","qos":"","retain":"","respTopic":"","contentType":"","userProps":"","correl":"","expiry":"","broker":"","x":1530,"y":150,"wires":[]}]