# Copyright 2021, Milan Meulemans.
# Modified and optimized 2025 by loebi-ch
# Added support for Nanoleaf Essentials based on the work of JaspervRijbroek in 2025
# Added support for 4D/Screen Mirroring emersion modes (1D, 2D, 3D, 4D) based on the work of jonathanrobichaud4 in 2024
# Added support for IPv6 hosts based on the work of krozgrov in 2025
#
# This file is part of aionanoleaf2, the refactored version of aionanoleaf by Milan Meulemans
#
# aionanoleaf2 is free software: you can redistribute it and/or modify
# it under the terms of the GNU Lesser General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# aionanoleaf2 is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU Lesser General Public License for more details.
#
# You should have received a copy of the GNU Lesser General Public License
# along with aionanoleaf2. If not, see .
from __future__ import annotations
import asyncio
import json
import socket
import ipaddress
from .layout import Panel
from typing import Any, Callable
from .typing import InfoData, EmersionData
from aiohttp import (
ClientConnectorError,
ClientError,
ClientResponse,
ClientSession,
ClientTimeout,
ClientConnectionError,
)
from .events import (
EffectsEvent,
LayoutEvent,
StateEvent,
TouchEvent,
TouchStreamEvent,
)
from .exceptions import (
InvalidEffect,
InvalidEmersion,
InvalidToken,
NanoleafException,
NoAuthToken,
Unauthorized,
Unavailable,
)
# Models that support Screen Mirroring
EMERSION_MODELS = ["NL69"]
# Mapping of integer IDs to readable Screen Mirroring mode names
EMERSION_MODES = {6: "1D", 2: "2D", 3: "3D", 5: "4D"}
# OPTIMIZATION: Pre-calculate an inverted map for lookups during set_emersion.
# This avoids iterating through the dictionary every time we set a mode.
EMERSION_MODES_INVERTED = {v: k for k, v in EMERSION_MODES.items()}
class Nanoleaf:
# Timeout settings for HTTP requests
_REQUEST_TIMEOUT = ClientTimeout(total=5, sock_connect=3)
# --- CONSTRUCTOR ---
def __init__(
self,
session: ClientSession,
host: str,
auth_token: str | None = None,
port: int = 16021,
retries: int = 3,
) -> None:
self._session = session
self._host = self._format_host(host)
self._auth_token = auth_token
self._port = port
self._retries = retries
# Initialize all internal attributes to defaults.
# This prevents AttributeErrors if properties are accessed before get_info() is called.
self._name = ""
self._serial_no = ""
self._manufacturer = ""
self._firmware_version = ""
self._hardware_version = None
self._model = ""
self._is_on = False
self._brightness = 0
self._brightness_max = 100
self._brightness_min = 0
self._hue = 0
self._hue_max = 360
self._hue_min = 0
self._saturation = 0
self._saturation_max = 100
self._saturation_min = 0
self._color_temperature = 0
self._color_temperature_max = 0
self._color_temperature_min = 0
self._color_mode = ""
self._effects_list = []
self._effect = ""
self._emersion_list = []
self._emersion = ""
self._panels = set()
# --- PROPERTIES (GETTERS) ---
@property
def host(self) -> str:
return self._host
@property
def auth_token(self) -> str:
if self._auth_token is None:
raise NoAuthToken(
"Authorize or set an auth_token before making this request."
)
return self._auth_token
@property
def port(self) -> int:
return self._port
@property
def name(self) -> str:
return self._name
@property
def serial_no(self) -> str:
return self._serial_no
@property
def manufacturer(self) -> str:
return self._manufacturer
@property
def firmware_version(self) -> str:
return self._firmware_version
@property
def hardware_version(self) -> str | None:
return self._hardware_version
@property
def model(self) -> str:
return self._model
@property
def is_on(self) -> bool:
return self._is_on
@property
def brightness(self) -> int:
return self._brightness
@property
def brightness_max(self) -> int:
return self._brightness_max
@property
def brightness_min(self) -> int:
return self._brightness_min
@property
def hue(self) -> int:
return self._hue
@property
def hue_max(self) -> int:
return self._hue_max
@property
def hue_min(self) -> int:
return self._hue_min
@property
def saturation(self) -> int:
return self._saturation
@property
def saturation_max(self) -> int:
return self._saturation_max
@property
def saturation_min(self) -> int:
return self._saturation_min
@property
def color_temperature(self) -> int:
return self._color_temperature
@property
def color_temperature_max(self) -> int:
return self._color_temperature_max
@property
def color_temperature_min(self) -> int:
return self._color_temperature_min
@property
def color_mode(self) -> str:
return self._color_mode
@property
def effects_list(self) -> list[str]:
return self._effects_list
@property
def effect(self) -> str:
return self._effect
@property
def selected_effect(self) -> str | None:
return self.effect if self.effect in self.effects_list else None
@property
def emersion_list(self) -> list[str]:
return self._emersion_list
@property
def emersion(self) -> str:
return self._emersion
@property
def selected_emersion(self) -> str | None:
return self.emersion if self.emersion in self.emersion_list else None
@property
def panels(self) -> set[Panel]:
return self._panels
@property
def _api_url(self) -> str:
return f"http://{self.host}:{self.port}/api/v1"
# --- PUBLIC METHODS ---
# Authorize the Nanoleaf device. Requests a new auth_token from the device. Requires holding the power button on the device for 5-7s before calling.
async def authorize(self) -> None:
try:
resp = await self._session.post(f"{self._api_url}/new")
except ClientConnectorError as err:
raise Unavailable from err
if resp.status == 403:
raise Unauthorized(
"Hold the on-off button down for 5-7 seconds until the LEDs start flashing or activate the API in the Nanoleaf app and try again within 30 seconds."
)
resp.raise_for_status()
self._auth_token = (await resp.json())["auth_token"]
# Deauthorize the Nanoleaf device. Deletes the current auth_token from the device.
async def deauthorize(self) -> None:
await self._request("delete", "")
self._auth_token = None
# Get the Nanoleaf device info and state.
async def get_info(self) -> None:
resp = await self._request("get", "")
data: InfoData = await resp.json()
# Populate device info
self._name = data["name"]
self._serial_no = data["serialNo"]
self._manufacturer = data["manufacturer"]
self._firmware_version = data["firmwareVersion"]
self._hardware_version = data.get("hardwareVersion")
self._model = data["model"]
# Populate state (light status)
state = data["state"]
self._is_on = state["on"]["value"]
self._brightness = state["brightness"]["value"]
self._brightness_max = state["brightness"]["max"]
self._brightness_min = state["brightness"]["min"]
self._hue = state["hue"]["value"]
self._hue_max = state["hue"]["max"]
self._hue_min = state["hue"]["min"]
self._saturation = state["sat"]["value"]
self._saturation_max = state["sat"]["max"]
self._saturation_min = state["sat"]["min"]
self._color_temperature = state["ct"]["value"]
self._color_temperature_max = state["ct"]["max"]
self._color_temperature_min = state["ct"]["min"]
self._color_mode = state["colorMode"]
# Nanoleaf Essentials are missing the effectsList in the main payload, so we have to fetch it separately.
effects = data.get("effects", {})
try:
self._effects_list = effects.get("effectsList") or await self.get_effects()
except Unavailable:
self._effects_list = []
# Nanoleaf Essentials are missing the selected effect, so we have to fetch it separately.
try:
self._effect = effects.get("select") or await self.get_selected_effect()
except Unavailable:
self._effect = ""
# Populate panels layout if available.
try:
self._panels = {Panel(panel) for panel in data["panelLayout"]["layout"]["positionData"]}
except KeyError:
self._panels = set()
# Populate Screen Mirroring mode if available.
if self._model in EMERSION_MODELS:
await self.get_emersion()
# Fetch the list of available effects for Nanoleaf Essentials.
async def get_effects(self) -> list[str]:
try:
resp = await self._request("get", "effects/effectsList")
return await resp.json()
except Unavailable:
return []
# Fetch the currently active effect for Nanoleaf Essentials.
async def get_selected_effect(self) -> str | None:
try:
resp = await self._request("get", "effects/select")
return await resp.json()
except Unavailable:
return None
# Fetch the current Screen Mirroring mode.
async def get_emersion(self) -> None:
self._emersion_list = list(EMERSION_MODES.values())
# The command to get screen mirror mode is a specific 'write' command
emersion_request = await self._request("put", "effects", {"write": {"command": "getScreenMirrorMode"}})
emersion_data: EmersionData = await emersion_request.json()
# Map the integer ID back to the string name
self._emersion = EMERSION_MODES.get(emersion_data["screenMirrorMode"], "Unknown")
# Update the state of the Nanoleaf device (on/off, brightness, color). Supports both absolute values and relative increments.
async def set_state(
self,
on: bool | None = None,
brightness: int | None = None,
brightness_relative: bool = False,
brightness_transition: int | None = None,
color_temperature: int | None = None,
color_temperature_relative: bool = False,
hue: int | None = None,
hue_relative: bool = False,
saturation: int | None = None,
saturation_relative: bool = False,
) -> None:
data = {}
# Helper to construct the JSON payload
def _add_topic_to_data(
topic: str, value: int | bool | None, relative: bool = False
) -> None:
if value is not None:
if relative:
data[topic] = {"increment": value}
else:
data[topic] = {"value": value}
_add_topic_to_data("brightness", brightness, brightness_relative)
if brightness_transition is not None and "brightness" in data:
data["brightness"]["duration"] = brightness_transition
_add_topic_to_data("ct", color_temperature, color_temperature_relative)
_add_topic_to_data("hue", hue, hue_relative)
_add_topic_to_data("sat", saturation, saturation_relative)
_add_topic_to_data("on", on) # API requires 'on' to be processed last
if data:
await self._request("put", "state", data)
# Activate a specific effect on the device.
async def set_effect(self, effect: str) -> None:
if effect not in self.effects_list:
raise InvalidEffect
await self._request("put", "effects", {"select": effect})
# Activate a Screen Mirroring mode on the device (if supported).
async def set_emersion(self, emersion: str) -> None:
if emersion not in self.emersion_list:
raise InvalidEmersion
# OPTIMIZATION: Use the inverted dictionary for fast lookup.
# This replaces the slow list index search.
emersion_int = EMERSION_MODES_INVERTED.get(emersion)
if emersion_int is None:
raise InvalidEmersion(f"Could not find ID for emersion mode {emersion}")
await self._request(
"put",
"effects",
{"write": {"command": "activateScreenMirror", "screenMirrorMode": emersion_int}}
)
# Refresh the state to ensure it applied
await self.get_emersion()
# Set absolute or relative brightness with or without transition of the Nanoleaf device.
async def set_brightness(
self, brightness: int, relative: bool = False, transition: int | None = None
) -> None:
await self._set_state("brightness", brightness, relative, transition)
# Set absolute or relative saturation of the Nanoleaf device.
async def set_saturation(self, saturation: int, relative: bool = False) -> None:
await self._set_state("sat", saturation, relative)
# Set absolute or relative hue of the Nanoleaf device.
async def set_hue(self, hue: int, relative: bool = False) -> None:
await self._set_state("hue", hue, relative)
# Set absolute or relative color temperature of the Nanoleaf device.
async def set_color_temperature(
self, color_temperature: int, relative: bool = False
) -> None:
await self._set_state("ct", color_temperature, relative)
# Turn the Nanoleaf device on.
async def turn_on(self) -> None:
await self._set_state("on", True)
# Turn the Nanoleaf device off.
async def turn_off(self, transition: int | None = None) -> None:
if transition is None:
await self._set_state("on", False)
else:
# If transition is requested, dim to 0 first
await self.set_brightness(0, transition=transition)
# Flash the panels of the Nanoleaf device for identification.
async def identify(self) -> None:
await self._request("put", "identify")
# Listen to the Nanoleaf device events.
async def listen_events(
self,
state_callback: Callable[[StateEvent], Any] | None = None,
layout_callback: Callable[[LayoutEvent], Any] | None = None,
effects_callback: Callable[[EffectsEvent], Any] | None = None,
touch_callback: Callable[[TouchEvent], Any] | None = None,
touch_stream_callback: Callable[[Any], Any] | None = None,
*,
local_ip: str | None = None,
local_port: int | None = None,
) -> None:
socket_port: int | None = None
# If user wants touch stream, setup UDP socket first
if touch_stream_callback is not None:
socket_port = await self._open_udp_socket_for_touch_data_stream(
touch_stream_callback, local_ip, local_port
)
await self._listen_for_server_sent_events(
state_callback,
layout_callback,
effects_callback,
touch_callback,
socket_port,
)
# --- PRIVATE HELPER METHODS ---
# Make an authorized request to the Nanoleaf device. Handles retries for network errors but fails fast for logic/auth errors.
async def _request(
self, method: str, path: str, data: dict | None = None
) -> ClientResponse:
url = f"{self._api_url}/{self.auth_token}/{path}"
json_data = json.dumps(data) if data is not None else None
last_error = None
# Logic to retry only on transient network errors.
for attempt in range(self._retries):
try:
resp = await self._session.request(
method, url, data=json_data, timeout=self._REQUEST_TIMEOUT
)
# Immediate failure on 401 (Auth invalid), no point in retrying.
if resp.status == 401:
raise InvalidToken
resp.raise_for_status()
return resp
except (ClientConnectionError, asyncio.TimeoutError) as err:
# Store error and try again if it's a connection issue.
last_error = err
except ClientError as err:
# Re-raise 401s if they happen inside ClientError wrapper
if hasattr(err, 'status') and err.status == 401:
raise InvalidToken
# Other HTTP errors (e.g. 404, 422) are raised immediately.
raise err
# If we exit the loop, retries were exhausted.
if last_error:
raise Unavailable from last_error
raise Unavailable("Unknown error occurred!")
# Bracket IPv6 literals and percent-encode zone IDs per RFC 6874.
def _format_host(self, host: str) -> str:
if not host:
return host
#Remove brackets
raw = host.strip().strip("[]")
#Split IP and zones
parts = raw.split("%", 1)
ip_part = parts[0]
try:
# Check for valid IPv6
ipaddress.IPv6Address(ip_part)
# Format IPv6
if len(parts) > 1:
return f"[{ip_part}%25{parts[1]}]"
return f"[{ip_part}]"
except ValueError:
return host # No valid IPv6 (we just return the original value)
# Set a single state attribute.
async def _set_state(
self,
topic: str,
value: int | bool,
relative: bool = False,
transition: int | None = None,
) -> None:
data: dict
if relative:
data = {topic: {"increment": value}}
else:
data = {topic: {"value": value}}
if transition is not None:
data[topic]["duration"] = transition
await self._request("put", "state", data)
# Open a local UDP socket to receive high-frequency touch stream events.
async def _open_udp_socket_for_touch_data_stream(
self,
callback: Callable,
local_ip: str | None = None,
local_port: int | None = None,
) -> int:
if local_ip is None:
local_ip = "0.0.0.0"
if local_port is None:
local_port = 0 # 0 means OS chooses a free port
loop = asyncio.get_running_loop()
# Create a Datagram (UDP) endpoint
transport, _ = await loop.create_datagram_endpoint(
lambda: _NanoleafTouchProtocol(self.host, callback),
local_addr=(local_ip, local_port),
)
touch_socket: socket.socket = transport.get_extra_info("socket")
socket_port = touch_socket.getsockname()[1]
if socket_port is None:
raise NanoleafException("Could not determine port of socket")
return socket_port
# Listen to events. Long-running task to listen to Server-Sent Events (SSE).
# SIGNIFICANT FIX: The parsing logic was rewritten to be robust against whitespace changes and encoding issues.
async def _listen_for_server_sent_events(
self,
state_callback: Callable[[StateEvent], Any] | None = None,
layout_callback: Callable[[LayoutEvent], Any] | None = None,
effects_callback: Callable[[EffectsEvent], Any] | None = None,
touch_callback: Callable[[TouchEvent], Any] | None = None,
socket_port: int | None = None,
) -> None:
# Construct URL based on desired event IDs
request_url = (
f"{self._api_url}/{self.auth_token}/events?"
f"id={StateEvent.EVENT_TYPE_ID},{EffectsEvent.EVENT_TYPE_ID}"
)
if layout_callback is not None:
request_url += f",{LayoutEvent.EVENT_TYPE_ID}"
if touch_callback is not None or socket_port is not None:
request_url += f",{TouchEvent.EVENT_TYPE_ID}"
request_headers = None
if socket_port is not None:
# Inform device where to send UDP touch stream
request_headers = {"TouchEventsPort": str(socket_port)}
# Infinite timeout for the read, but strict timeout for connecting
request_timeout = ClientTimeout(total=None, sock_connect=5, sock_read=None)
while True:
try:
async with self._session.get(
request_url, headers=request_headers, timeout=request_timeout
) as resp:
if resp.status != 200:
# Wait and retry on failure
await asyncio.sleep(5)
continue
while True:
# FIX: Read line bytes and decode properly
line_bytes = await resp.content.readline()
if not line_bytes: # Stream closed by server
break
# Safely decode and strip whitespace
line = line_bytes.decode('utf-8').strip()
if not line: # Skip empty keep-alive lines
continue
# Parse 'id: '
if line.startswith("id:"):
try:
# Split by first colon only to handle malformed data gracefully
event_type_id = int(line.split(":", 1)[1].strip())
except ValueError:
continue
# Read the next line which should be 'data: '
data_bytes = await resp.content.readline()
data_line = data_bytes.decode('utf-8').strip()
if not data_line.startswith("data:"):
continue
# Parse JSON data
try:
json_str = data_line.split(":", 1)[1].strip()
data = json.loads(json_str)
except (IndexError, json.JSONDecodeError):
continue
# Dispatch events to callbacks
for event_data in data.get("events", []):
if event_type_id == StateEvent.EVENT_TYPE_ID:
event = StateEvent(event_data)
# Update internal state if attribute exists
if hasattr(self, f"_{event.attribute}"):
setattr(self, f"_{event.attribute}", event.value)
if state_callback:
asyncio.create_task(state_callback(event))
elif event_type_id == LayoutEvent.EVENT_TYPE_ID:
layout_event = LayoutEvent(event_data)
if layout_callback:
asyncio.create_task(layout_callback(layout_event))
elif event_type_id == EffectsEvent.EVENT_TYPE_ID:
effects_event = EffectsEvent(event_data)
self._effect = effects_event.effect
# If Screen Mirroring mode is active, fetch details
if effects_event.effect == "*Emersion*":
await self.get_emersion()
if effects_callback:
asyncio.create_task(effects_callback(effects_event))
elif event_type_id == TouchEvent.EVENT_TYPE_ID:
touch_event = TouchEvent(event_data)
if touch_callback:
asyncio.create_task(touch_callback(touch_event))
except ClientError:
# Connection dropped, wait and reconnect
await asyncio.sleep(5)
# Protocol to handle UDP touch stream packets from the Nanoleaf device.
class _NanoleafTouchProtocol(asyncio.DatagramProtocol):
def __init__(
self, nanoleaf_host: str, callback: Callable[[TouchStreamEvent], Any]
) -> None:
self._nanoleaf_host = nanoleaf_host
self._callback = callback
def connection_made(self, transport: asyncio.BaseTransport) -> None:
self.transport = transport
def datagram_received(self, data: bytes, addr: Any) -> None:
# Security check: only accept packets from the known Nanoleaf host
if addr[0] != self._nanoleaf_host:
return
# Parse the binary protocol manually
# Note: This could be optimized using struct.unpack instead of string manipulation
binary = bin(int.from_bytes(data, byteorder="big"))
binary = binary[3:] # Remove '0b1' prefix padding if present
event = TouchStreamEvent(
panel_id=int(binary[:16], 2), # First 2 bytes
touch_type_id=int(binary[16:20], 2), # Nibble after panel id
strength=int(binary[20:24], 2), # Nibble after touch type
panel_id_2=int(binary[24:], 2), # Remaining bits
)
asyncio.create_task(self._callback(event))