Skip to content

Commit

Permalink
[Nanoleaf] Visualize layout (openhab#13552)
Browse files Browse the repository at this point in the history
* Visualize Nanoleaf layout
* Only calculate image if channel is linked
* White background image
* Render more shapes

Signed-off-by: Jørgen Austvik <jaustvik@acm.org>
  • Loading branch information
austvik authored and theater committed Nov 13, 2022
1 parent c249529 commit ec05eff
Show file tree
Hide file tree
Showing 25 changed files with 1,278 additions and 29 deletions.
20 changes: 15 additions & 5 deletions bundles/org.openhab.binding.nanoleaf/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -28,9 +28,13 @@ You can set the **color** for each panel and in the case of a Nanoleaf Canvas or
| Nanoleaf Name | Type | Description | supported | touch support |
| ---------------------- | ---- | ---------------------------------------------------------- | --------- | ------------- |
| Light Panels | NL22 | Triangles 1st Generation | X | - |
| Shapes Triangle | NL42 | Triangles 2nd Generation (rounded edges) | X | X |
| Shapes Hexagon | NL42 | Hexagons | X | X |
| Shapes Mini Triangles | NL42 | Mini Triangles | x | X |
| Shapes Triangles | NL47 | Triangles | X | X |
| Shapes Mini Triangles | NL48 | Mini Triangles | X | X |
| Elements Hexagon | NL52 | Elements Hexagons | X | X |
| Smart Bulb | NL45 | Smart Bulb | - | |
| Lightstrip | NL55 | Lightstrip | - | |
| Lines | NL59 | Lines | - | |
| Canvas | NL29 | Squares | X | X |

x = Supported (-) = unknown (no device available to test)
Expand Down Expand Up @@ -70,9 +74,15 @@ In this case:

### Panel Layout

Unfortunately it is not easy to find out which panel gets which id, and this becomes pretty important if you have lots of them and want to assign rules.
If you want to program individual panels, it can be hard to figure out which panel has which ID. To make this easier, there is Layout channel on the Nanoleaf controller thing in openHAB.
The easiest way to visualize the layout of the individual panels is to open the controller thing in the openHAB UI, go to Channels and add a new item to the Layout channel.
Clicking on that image or adding it to a dashboard will show a picture of your canvas with the individual thing ID in the picture.

For canvas that use square panels, you can request the layout through a [console command](https://www.openhab.org/docs/administration/console.html):
If your canvas has elements we dont know how to draw a layout for yet, please reach out, and we will ask for some information and will try to add support for your elements.

![Image](doc/Layout.jpg)

There is an alternative method for canvas that use square panels, you can request the layout through a [console command](https://www.openhab.org/docs/administration/console.html):

then issue the following command:

Expand All @@ -94,7 +104,7 @@ Compare the following output with the right picture at the beginning of the arti
41451
```
## Thing Configuration

The controller thing has the following parameters:
Expand Down
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,7 @@ public class NanoleafBindingConstants {
public static final String CHANNEL_SWIPE_EVENT_DOWN = "DOWN";
public static final String CHANNEL_SWIPE_EVENT_LEFT = "LEFT";
public static final String CHANNEL_SWIPE_EVENT_RIGHT = "RIGHT";
public static final String CHANNEL_LAYOUT = "layout";

// List of light panel channels
public static final String CHANNEL_PANEL_COLOR = "color";
Expand All @@ -78,7 +79,7 @@ public class NanoleafBindingConstants {
public static final String API_MIN_FW_VER_CANVAS = "1.1.0";
public static final String MODEL_ID_LIGHTPANELS = "NL22";

public static final List<String> MODELS_WITH_TOUCHSUPPORT = Arrays.asList("NL29", "NL42");
public static final List<String> MODELS_WITH_TOUCHSUPPORT = Arrays.asList("NL29", "NL42", "NL47", "NL48", "NL52");
public static final String DEVICE_TYPE_LIGHTPANELS = "lightPanels";
public static final String DEVICE_TYPE_TOUCHSUPPORT = "canvas"; // we need to keep this enum for backward
// compatibility even though not only canvas type
Expand All @@ -93,4 +94,8 @@ public class NanoleafBindingConstants {

// Color channels increase/decrease brightness step size
public static final int BRIGHTNESS_STEP_SIZE = 5;

// Layout rendering
public static final int LAYOUT_LIGHT_RADIUS = 8;
public static final int LAYOUT_BORDER_WIDTH = 30;
}
Original file line number Diff line number Diff line change
Expand Up @@ -154,11 +154,7 @@ public static boolean checkRequiredFirmware(@Nullable String modelId, @Nullable

for (int i = 0; i < currentVer.length; ++i) {
if (currentVer[i] != requiredVer[i]) {
if (currentVer[i] > requiredVer[i]) {
return true;
}

return false;
return (currentVer[i] > requiredVer[i]);
}
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@

import static org.openhab.binding.nanoleaf.internal.NanoleafBindingConstants.*;

import java.io.IOException;
import java.net.URI;
import java.nio.charset.StandardCharsets;
import java.util.Collection;
Expand Down Expand Up @@ -43,6 +44,7 @@
import org.openhab.binding.nanoleaf.internal.commanddescription.NanoleafCommandDescriptionProvider;
import org.openhab.binding.nanoleaf.internal.config.NanoleafControllerConfig;
import org.openhab.binding.nanoleaf.internal.discovery.NanoleafPanelsDiscoveryService;
import org.openhab.binding.nanoleaf.internal.layout.NanoleafLayout;
import org.openhab.binding.nanoleaf.internal.model.AuthToken;
import org.openhab.binding.nanoleaf.internal.model.BooleanState;
import org.openhab.binding.nanoleaf.internal.model.Brightness;
Expand All @@ -65,13 +67,15 @@
import org.openhab.core.library.types.IncreaseDecreaseType;
import org.openhab.core.library.types.OnOffType;
import org.openhab.core.library.types.PercentType;
import org.openhab.core.library.types.RawType;
import org.openhab.core.library.types.StringType;
import org.openhab.core.thing.Bridge;
import org.openhab.core.thing.ChannelUID;
import org.openhab.core.thing.Thing;
import org.openhab.core.thing.ThingStatus;
import org.openhab.core.thing.ThingStatusDetail;
import org.openhab.core.thing.binding.BaseBridgeHandler;
import org.openhab.core.thing.binding.ThingHandlerCallback;
import org.openhab.core.thing.binding.ThingHandlerService;
import org.openhab.core.types.Command;
import org.openhab.core.types.RefreshType;
Expand Down Expand Up @@ -103,6 +107,7 @@ public class NanoleafControllerHandler extends BaseBridgeHandler {
private @Nullable HttpClient httpClientSSETouchEvent;
private @Nullable Request sseTouchjobRequest;
private List<NanoleafControllerListener> controllerListeners = new CopyOnWriteArrayList<NanoleafControllerListener>();
private PanelLayout previousPanelLayout = new PanelLayout();

private @NonNullByDefault({}) ScheduledFuture<?> pairingJob;
private @NonNullByDefault({}) ScheduledFuture<?> updateJob;
Expand Down Expand Up @@ -664,6 +669,7 @@ private void updateFromControllerInfo() throws NanoleafException {

updateProperties();
updateConfiguration();
updateLayout(controllerInfo.getPanelLayout());

for (NanoleafControllerListener controllerListener : controllerListeners) {
controllerListener.onControllerInfoFetched(getThing().getUID(), controllerInfo);
Expand Down Expand Up @@ -705,6 +711,33 @@ private void updateProperties() {
}
}

private void updateLayout(PanelLayout panelLayout) {
ChannelUID layoutChannel = new ChannelUID(getThing().getUID(), CHANNEL_LAYOUT);
ThingHandlerCallback callback = getCallback();
if (callback != null) {
if (!callback.isChannelLinked(layoutChannel)) {
// Don't generate image unless it is used
return;
}
}

if (previousPanelLayout.equals(panelLayout)) {
logger.trace("Not rendering panel layout as it is the same as previous rendered panel layout");
return;
}

try {
byte[] bytes = NanoleafLayout.render(panelLayout);
if (bytes.length > 0) {
updateState(CHANNEL_LAYOUT, new RawType(bytes, "image/png"));
}

previousPanelLayout = panelLayout;
} catch (IOException ioex) {
logger.warn("Failed to create layout image", ioex);
}
}

private ControllerInfo receiveControllerInfo() throws NanoleafException, NanoleafUnauthorizedException {
ContentResponse controllerlInfoJSON = OpenAPIUtils.sendOpenAPIRequest(OpenAPIUtils.requestBuilder(httpClient,
getControllerConfig(), API_GET_CONTROLLER_INFO, HttpMethod.GET));
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
/**
* Copyright (c) 2010-2022 Contributors to the openHAB project
*
* See the NOTICE file(s) distributed with this work for additional
* information.
*
* This program and the accompanying materials are made available under the
* terms of the Eclipse Public License 2.0 which is available at
* http://www.eclipse.org/legal/epl-2.0
*
* SPDX-License-Identifier: EPL-2.0
*/
package org.openhab.binding.nanoleaf.internal.layout;

import org.eclipse.jdt.annotation.NonNullByDefault;

/**
* Differentiates how shapes must be drawn
*
* @author Jørgen Austvik - Initial contribution
*/
@NonNullByDefault
public enum DrawingAlgorithm {
NONE,
SQUARE,
TRIANGLE,
HEXAGON,
CORNER,
LINE;
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,184 @@
/**
* Copyright (c) 2010-2022 Contributors to the openHAB project
*
* See the NOTICE file(s) distributed with this work for additional
* information.
*
* This program and the accompanying materials are made available under the
* terms of the Eclipse Public License 2.0 which is available at
* http://www.eclipse.org/legal/epl-2.0
*
* SPDX-License-Identifier: EPL-2.0
*/

package org.openhab.binding.nanoleaf.internal.layout;

import java.awt.Color;
import java.awt.Graphics2D;
import java.awt.image.BufferedImage;
import java.io.ByteArrayOutputStream;
import java.io.IOException;
import java.util.ArrayList;
import java.util.Collection;
import java.util.List;

import javax.imageio.ImageIO;

import org.eclipse.jdt.annotation.NonNullByDefault;
import org.openhab.binding.nanoleaf.internal.NanoleafBindingConstants;
import org.openhab.binding.nanoleaf.internal.layout.shape.Shape;
import org.openhab.binding.nanoleaf.internal.layout.shape.ShapeFactory;
import org.openhab.binding.nanoleaf.internal.model.GlobalOrientation;
import org.openhab.binding.nanoleaf.internal.model.Layout;
import org.openhab.binding.nanoleaf.internal.model.PanelLayout;
import org.openhab.binding.nanoleaf.internal.model.PositionDatum;

/**
* Renders the Nanoleaf layout to an image.
*
* @author Jørgen Austvik - Initial contribution
*/
@NonNullByDefault
public class NanoleafLayout {

private static final Color COLOR_BACKGROUND = Color.WHITE;
private static final Color COLOR_PANEL = Color.BLACK;
private static final Color COLOR_SIDE = Color.GRAY;
private static final Color COLOR_TEXT = Color.BLACK;

public static byte[] render(PanelLayout panelLayout) throws IOException {
double rotationRadians = 0;
GlobalOrientation globalOrientation = panelLayout.getGlobalOrientation();
if (globalOrientation != null) {
rotationRadians = calculateRotationRadians(globalOrientation);
}

Layout layout = panelLayout.getLayout();
if (layout == null) {
return new byte[] {};
}

List<PositionDatum> panels = layout.getPositionData();
if (panels == null) {
return new byte[] {};
}

Point2D size[] = findSize(panels, rotationRadians);
final Point2D min = size[0];
final Point2D max = size[1];
Point2D prev = null;
Point2D first = null;

int sideCounter = 0;
BufferedImage image = new BufferedImage(
(max.getX() - min.getX()) + 2 * NanoleafBindingConstants.LAYOUT_BORDER_WIDTH,
(max.getY() - min.getY()) + 2 * NanoleafBindingConstants.LAYOUT_BORDER_WIDTH,
BufferedImage.TYPE_INT_RGB);
Graphics2D g2 = image.createGraphics();

g2.setBackground(COLOR_BACKGROUND);
g2.clearRect(0, 0, image.getWidth(), image.getHeight());

for (PositionDatum panel : panels) {
final ShapeType shapeType = ShapeType.valueOf(panel.getShapeType());

Shape shape = ShapeFactory.CreateShape(shapeType, panel);
List<Point2D> outline = toPictureLayout(shape.generateOutline(), image.getHeight(), min, rotationRadians);
for (int i = 0; i < outline.size(); i++) {
g2.setColor(COLOR_SIDE);
Point2D pos = outline.get(i);
Point2D nextPos = outline.get((i + 1) % outline.size());
g2.drawLine(pos.getX(), pos.getY(), nextPos.getX(), nextPos.getY());
}

for (int i = 0; i < outline.size(); i++) {
Point2D pos = outline.get(i);
g2.setColor(COLOR_PANEL);
g2.fillOval(pos.getX() - NanoleafBindingConstants.LAYOUT_LIGHT_RADIUS / 2,
pos.getY() - NanoleafBindingConstants.LAYOUT_LIGHT_RADIUS / 2,
NanoleafBindingConstants.LAYOUT_LIGHT_RADIUS, NanoleafBindingConstants.LAYOUT_LIGHT_RADIUS);
}

Point2D current = toPictureLayout(new Point2D(panel.getPosX(), panel.getPosY()), image.getHeight(), min,
rotationRadians);
if (sideCounter == 0) {
first = current;
}

g2.setColor(COLOR_SIDE);
final int expectedSides = shapeType.getNumSides();
if (shapeType.getDrawingAlgorithm() == DrawingAlgorithm.CORNER) {
// Special handling of Elements Hexagon Corners, where we get 6 corners instead of 1 shape. They seem to
// come after each other in the JSON, so this algorithm connects them based on the number of sides the
// shape is expected to have.
if (sideCounter > 0 && sideCounter != expectedSides && prev != null) {
g2.drawLine(prev.getX(), prev.getY(), current.getX(), current.getY());
}

sideCounter++;

if (sideCounter == expectedSides && first != null) {
g2.drawLine(current.getX(), current.getY(), first.getX(), first.getY());
sideCounter = 0;
}
} else {
sideCounter = 0;
}

prev = current;

g2.setColor(COLOR_TEXT);
Point2D textPos = shape.labelPosition(g2, outline);
g2.drawString(Integer.toString(panel.getPanelId()), textPos.getX(), textPos.getY());
}

ByteArrayOutputStream out = new ByteArrayOutputStream();
ImageIO.write(image, "png", out);
return out.toByteArray();
}

private static double calculateRotationRadians(GlobalOrientation globalOrientation) {
Integer maxObj = globalOrientation.getMax();
int maxValue = maxObj == null ? 360 : (int) maxObj;
int value = globalOrientation.getValue(); // 0 - 360 measured counter clockwise.
return ((double) (maxValue - value)) * (Math.PI / 180);
}

private static Point2D[] findSize(Collection<PositionDatum> panels, double rotationRadians) {
int maxX = 0;
int maxY = 0;
int minX = 0;
int minY = 0;

for (PositionDatum panel : panels) {
ShapeType shapeType = ShapeType.valueOf(panel.getShapeType());
Shape shape = ShapeFactory.CreateShape(shapeType, panel);
for (Point2D point : shape.generateOutline()) {
var rotated = point.rotate(rotationRadians);
maxX = Math.max(rotated.getX(), maxX);
maxY = Math.max(rotated.getY(), maxY);
minX = Math.min(rotated.getX(), minX);
minY = Math.min(rotated.getY(), minY);
}
}

return new Point2D[] { new Point2D(minX, minY), new Point2D(maxX, maxY) };
}

private static Point2D toPictureLayout(Point2D original, int imageHeight, Point2D min, double rotationRadians) {
Point2D rotated = original.rotate(rotationRadians);
Point2D translated = new Point2D(NanoleafBindingConstants.LAYOUT_BORDER_WIDTH + rotated.getX() - min.getX(),
imageHeight - NanoleafBindingConstants.LAYOUT_BORDER_WIDTH - rotated.getY() + min.getY());
return translated;
}

private static List<Point2D> toPictureLayout(List<Point2D> originals, int imageHeight, Point2D min,
double rotationRadians) {
List<Point2D> result = new ArrayList<Point2D>(originals.size());
for (Point2D original : originals) {
result.add(toPictureLayout(original, imageHeight, min, rotationRadians));
}

return result;
}
}
Loading

0 comments on commit ec05eff

Please sign in to comment.