Skip to content

Commit e2e75a9

Browse files
author
Alex J Lennon
committed
Enable Bluetooth Improv for imx93-jaguar-eink with unique board ID
- Enable improv feature for imx93-jaguar-eink in lmp-dynamicdevices-headless.conf - Add machine-specific improv service and onboarding script: * improv-eink.service: No network dependency (starts without WiFi) * onboarding-server-eink.py: Unique BLE name from SOC serial (eink-XXXX) - Update python3-improv_git.bb to use machine-specific files pattern - Use machine-named subdirectory directly (imx93-jaguar-eink/) per Yocto standards - Add MACHINE_SPECIFIC_FILES.md documentation for future reference The improv service: - Advertises unique BLE name: 'eink-XXXX' where XXXX is last 4 chars of SOC serial - Starts without network dependency (allows WiFi configuration) - Automatically restarts after sleep/wake cycles - Only affects imx93-jaguar-eink, other boards unchanged
1 parent eec679f commit e2e75a9

File tree

5 files changed

+389
-3
lines changed

5 files changed

+389
-3
lines changed
Lines changed: 99 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,99 @@
1+
# Machine-Specific Files Pattern for Yocto Recipes
2+
3+
## Standard Pattern
4+
5+
When creating machine-specific files in Yocto recipes, use **machine-named subdirectories directly** in the recipe directory, **NOT** a `files/` wrapper directory.
6+
7+
## Directory Structure
8+
9+
```
10+
recipes-devtools/python/python3-improv/
11+
├── common-file.service (common - all machines)
12+
├── common-script.py (common - all machines)
13+
├── recipe_name.bb (recipe file)
14+
└── imx93-jaguar-eink/ (machine-specific folder)
15+
├── machine-specific.service
16+
└── machine-specific-script.py
17+
```
18+
19+
## Recipe Configuration
20+
21+
### 1. Extend File Search Path
22+
23+
```bitbake
24+
# Extend file search path to include machine-specific directories
25+
# Yocto will automatically look in ${MACHINE}/ before recipe directory
26+
FILESEXTRAPATHS_prepend := "${THISDIR}:"
27+
```
28+
29+
### 2. Conditionally Include Machine-Specific Files in SRC_URI
30+
31+
```bitbake
32+
SRC_URI = "git://github.com/example/repo.git;branch=main \
33+
file://common-file.service \
34+
file://common-script.py \
35+
${@bb.utils.contains('MACHINE', 'imx93-jaguar-eink', 'file://machine-specific.service file://machine-specific-script.py', '', d)} \
36+
"
37+
```
38+
39+
### 3. Install Machine-Specific Files Conditionally
40+
41+
```bitbake
42+
do_install() {
43+
# Install common files
44+
install -m 0644 ${WORKDIR}/common-file.service ${D}/${systemd_unitdir}/system
45+
46+
# Install machine-specific files if they exist
47+
# Yocto automatically picks up ${MACHINE}/* files
48+
if [ -f ${WORKDIR}/machine-specific.service ]; then
49+
install -m 0644 ${WORKDIR}/machine-specific.service ${D}/${systemd_unitdir}/system
50+
fi
51+
if [ -f ${WORKDIR}/machine-specific-script.py ]; then
52+
install -m 0755 ${WORKDIR}/machine-specific-script.py ${D}${datadir}/app
53+
fi
54+
}
55+
```
56+
57+
### 4. Conditionally Select Machine-Specific Service
58+
59+
```bitbake
60+
# Use machine-specific service if it exists, otherwise use default
61+
# Yocto automatically picks up files from ${MACHINE}/ subdirectory
62+
SYSTEMD_SERVICE:${PN} = "${@bb.utils.contains('MACHINE', 'imx93-jaguar-eink', 'machine-specific.service', 'common-file.service', d)}"
63+
```
64+
65+
## How It Works
66+
67+
1. **FILESEXTRAPATHS**: Extends the file search path to the recipe directory (`THISDIR`)
68+
2. **Automatic Lookup**: Yocto automatically looks in `${MACHINE}/` subdirectory first
69+
3. **Machine-Specific**: For `imx93-jaguar-eink`, files are found in `imx93-jaguar-eink/`
70+
4. **Other Machines**: For other machines, files are found in the recipe directory root
71+
72+
## Example: python3-improv Recipe
73+
74+
**Structure:**
75+
```
76+
recipes-devtools/python/python3-improv/
77+
├── improv.service (common - all machines)
78+
├── onboarding-server.py (common - all machines)
79+
├── python3-improv_git.bb (recipe)
80+
└── imx93-jaguar-eink/ (machine-specific folder)
81+
├── improv-eink.service
82+
└── onboarding-server-eink.py
83+
```
84+
85+
**Result:**
86+
- `imx93-jaguar-eink`: Uses files from `imx93-jaguar-eink/` subdirectory
87+
- All other machines (sentai, etc.): Use files from recipe directory root
88+
- No changes to original files - other machine behavior preserved
89+
90+
## Key Points
91+
92+
**DO**: Use machine-named subdirectories directly (e.g., `imx93-jaguar-eink/`)
93+
**DON'T**: Use a `files/` wrapper directory (e.g., `files/imx93-jaguar-eink/`)
94+
95+
**DO**: Use `FILESEXTRAPATHS_prepend := "${THISDIR}:"`
96+
**DON'T**: Use `FILESEXTRAPATHS_prepend := "${THISDIR}/files:"`
97+
98+
This is the **standard Yocto pattern** - cleaner, simpler, and more maintainable.
99+
Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
[Unit]
2+
Description=Improv BLE WiFi onboarding service for imx93-jaguar-eink
3+
Documentation=https://www.improv-wifi.com/ble/
4+
# CRITICAL: Must start WITHOUT network - this service is used to CONFIGURE WiFi!
5+
# Only require Bluetooth to be available, network is optional
6+
After=bluetooth.target
7+
Wants=bluetooth.target
8+
# Do NOT require network.target - service must run even without network connection
9+
# This allows users to configure WiFi when no network is available
10+
11+
[Service]
12+
Type=simple
13+
ExecStart=/usr/share/improv/onboarding-server-eink.py
14+
Restart=always
15+
RestartSec=12
16+
# CRITICAL: Service must survive system sleep/wake cycles
17+
# When PMIC is turned off (pm sleep --alloff), the entire system reboots on wake
18+
# This service will automatically restart after reboot via Restart=always
19+
# Board-specific environment variables for imx93-jaguar-eink
20+
Environment="IMPROV_WIFI_INTERFACE=wlan0"
21+
Environment="IMPROV_SERVICE_NAME=Improv-Eink"
22+
Environment="IMPROV_CONNECTION_NAME=improv-eink"
23+
# Optional: Override server host if needed
24+
# Environment="IMPROV_SERVER_HOST=api.co.uk"
25+
26+
[Install]
27+
WantedBy=default.target
28+
Lines changed: 237 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,237 @@
1+
#!/usr/bin/env python3
2+
# -*- coding: utf-8 -*-
3+
#
4+
# Custom Improv onboarding server for imx93-jaguar-eink board
5+
# Based on onboarding-server.py but with board-specific customizations
6+
#
7+
8+
from improv import *
9+
from bless import ( # type: ignore
10+
BlessServer,
11+
BlessGATTCharacteristic,
12+
GATTCharacteristicProperties,
13+
GATTAttributePermissions
14+
)
15+
from bless.backends.bluezdbus.server import BlessServerBlueZDBus
16+
from typing import Any, Dict, Union, Optional
17+
import sys
18+
import threading
19+
import asyncio
20+
import logging
21+
import uuid
22+
import nmcli
23+
import os
24+
import re
25+
26+
logging.basicConfig(level=logging.DEBUG)
27+
logger = logging.getLogger(name=__name__)
28+
29+
# NOTE: Some systems require different synchronization methods.
30+
trigger: Union[asyncio.Event, threading.Event]
31+
if sys.platform in ["darwin", "win32"]:
32+
trigger = threading.Event()
33+
else:
34+
trigger = asyncio.Event()
35+
36+
logging.basicConfig(level=logging.DEBUG)
37+
logger = logging.getLogger(name=__name__)
38+
39+
40+
def build_gatt():
41+
gatt: Dict = {
42+
ImprovUUID.SERVICE_UUID.value: {
43+
ImprovUUID.STATUS_UUID.value: {
44+
"Properties": (GATTCharacteristicProperties.read |
45+
GATTCharacteristicProperties.notify),
46+
"Permissions": (GATTAttributePermissions.readable |
47+
GATTAttributePermissions.writeable)
48+
},
49+
ImprovUUID.ERROR_UUID.value: {
50+
"Properties": (GATTCharacteristicProperties.read |
51+
GATTCharacteristicProperties.notify),
52+
"Permissions": (GATTAttributePermissions.readable |
53+
GATTAttributePermissions.writeable)
54+
},
55+
ImprovUUID.RPC_COMMAND_UUID.value: {
56+
"Properties": (GATTCharacteristicProperties.read |
57+
GATTCharacteristicProperties.write |
58+
GATTCharacteristicProperties.write_without_response),
59+
"Permissions": (GATTAttributePermissions.readable |
60+
GATTAttributePermissions.writeable)
61+
},
62+
ImprovUUID.RPC_RESULT_UUID.value: {
63+
"Properties": (GATTCharacteristicProperties.read |
64+
GATTCharacteristicProperties.notify),
65+
"Permissions": (GATTAttributePermissions.readable)
66+
},
67+
ImprovUUID.CAPABILITIES_UUID.value: {
68+
"Properties": (GATTCharacteristicProperties.read),
69+
"Permissions": (GATTAttributePermissions.readable)
70+
},
71+
}
72+
}
73+
return gatt
74+
75+
"""
76+
Names longer than 10 characters will result in bless
77+
only advertising the name without the UUIDs on macOS,
78+
leading to a break with the Improv spec:
79+
80+
Bluetooth LE Advertisement
81+
The device MUST advertise the Service UUID.
82+
"""
83+
84+
def get_board_id():
85+
"""Get unique board ID from SOC serial number.
86+
87+
Reads the SOC serial number from /sys/devices/soc0/serial_number
88+
and extracts the last 4 characters to create a unique board identifier.
89+
90+
Returns:
91+
str: Board ID in format "XXXX" (4 hex characters), or "0000" if unavailable
92+
"""
93+
try:
94+
# Read SOC serial number (32-character hex string)
95+
soc_serial_path = "/sys/devices/soc0/serial_number"
96+
if os.path.exists(soc_serial_path):
97+
with open(soc_serial_path, 'r') as f:
98+
serial = f.read().strip()
99+
# Extract last 4 characters (most unique portion)
100+
# Remove any non-hex characters and take last 4
101+
serial_clean = re.sub(r'[^0-9a-fA-F]', '', serial)
102+
if len(serial_clean) >= 4:
103+
board_id = serial_clean[-4:].upper() # Last 4 chars, uppercase
104+
logger.info(f"Board ID from SOC serial: {board_id}")
105+
return board_id
106+
logger.warning("SOC serial number not found, using default board ID")
107+
except Exception as e:
108+
logger.error(f"Error reading board ID: {e}")
109+
110+
# Fallback to default if unavailable
111+
return "0000"
112+
113+
# Board-specific configuration for imx93-jaguar-eink
114+
# Can be overridden via environment variables
115+
SERVER_HOST = os.getenv("IMPROV_SERVER_HOST", "api.co.uk")
116+
# Generate unique service name from board ID: "eink-XXXX" where XXXX is last 4 chars of SOC serial
117+
BOARD_ID = get_board_id()
118+
DEFAULT_SERVICE_NAME = f"eink-{BOARD_ID}"
119+
SERVICE_NAME = os.getenv("IMPROV_SERVICE_NAME", DEFAULT_SERVICE_NAME)
120+
CON_NAME = os.getenv("IMPROV_CONNECTION_NAME", "improv-eink")
121+
INTERFACE = os.getenv("IMPROV_WIFI_INTERFACE", "wlan0") # imx93-jaguar-eink uses wlan0
122+
TIMEOUT = int(os.getenv("IMPROV_CONNECTION_TIMEOUT", "10000"))
123+
124+
loop = asyncio.get_event_loop()
125+
server = BlessServer(name=SERVICE_NAME, loop=loop)
126+
127+
def wifi_connect(ssid: str, passwd: str) -> Optional[list[str]]:
128+
logger.warning(
129+
f"Creating Improv WiFi connection for '{ssid.decode('utf-8')}' with password: '{passwd.decode('utf-8')}'")
130+
131+
try:
132+
nmcli.connection.delete(f"{CON_NAME}")
133+
except:
134+
print(f'No connection {CON_NAME} to remove')
135+
136+
try:
137+
nmcli.connection.add('wifi', { 'ssid':ssid.decode('utf-8'), 'wifi-sec.key-mgmt':'wpa-psk', 'wifi-sec.psk':passwd.decode('utf-8') }, f"{INTERFACE}", f"{CON_NAME}", True)
138+
except:
139+
print(f'Could not add new connection {CON_NAME}')
140+
return None
141+
142+
try:
143+
nmcli.connection.up(f"{CON_NAME}", TIMEOUT)
144+
except:
145+
print(f'Error bringing connection {CON_NAME} up')
146+
return None
147+
148+
dev_details = nmcli.device.show(f"{INTERFACE}")
149+
if 'IP4.ADDRESS[1]' in dev_details.keys():
150+
dev_addr = dev_details['IP4.ADDRESS[1]']
151+
ip_addr = dev_addr.split('/')[0]
152+
else:
153+
print('Error connecting')
154+
return None
155+
156+
token = uuid.uuid4()
157+
server = f"https://{SERVER_HOST}?ip_address={ip_addr}&token={token}"
158+
return [server]
159+
160+
improv_server = ImprovProtocol(wifi_connect_callback=wifi_connect)
161+
162+
def read_request(
163+
characteristic: BlessGATTCharacteristic,
164+
**kwargs
165+
) -> bytearray:
166+
try:
167+
improv_char = ImprovUUID(characteristic.uuid)
168+
logger.info(f"Reading {improv_char} : {characteristic}")
169+
except Exception:
170+
logger.info(f"Reading {characteristic.uuid}")
171+
pass
172+
if characteristic.service_uuid == ImprovUUID.SERVICE_UUID.value:
173+
return improv_server.handle_read(characteristic.uuid)
174+
return characteristic.value
175+
176+
177+
def write_request(
178+
characteristic: BlessGATTCharacteristic,
179+
value: bytearray,
180+
**kwargs
181+
):
182+
183+
if characteristic.service_uuid == ImprovUUID.SERVICE_UUID.value:
184+
(target_uuid, target_values) = improv_server.handle_write(
185+
characteristic.uuid, value)
186+
if target_uuid != None and target_values != None:
187+
for value in target_values:
188+
logger.debug(
189+
f"Setting {ImprovUUID(target_uuid)} to {value}")
190+
server.get_characteristic(
191+
target_uuid,
192+
).value = value
193+
success = server.update_value(
194+
ImprovUUID.SERVICE_UUID.value,
195+
target_uuid
196+
)
197+
if not success:
198+
logger.warning(
199+
f"Updating characteristic return status={success}")
200+
201+
async def run(loop):
202+
203+
server.read_request_func = read_request
204+
server.write_request_func = write_request
205+
206+
if isinstance(server, BlessServerBlueZDBus):
207+
await server.setup_task
208+
interface = server.adapter.get_interface('org.bluez.Adapter1')
209+
powered = await interface.get_powered()
210+
if not powered:
211+
logger.info("bluetooth device is not powered, powering now!")
212+
await interface.set_powered(True)
213+
214+
await server.add_gatt(build_gatt())
215+
await server.start()
216+
217+
logger.info("Server started")
218+
219+
try:
220+
trigger.clear()
221+
if trigger.__module__ == "threading":
222+
trigger.wait()
223+
else:
224+
await trigger.wait()
225+
except KeyboardInterrupt:
226+
logger.debug("Shutting Down")
227+
pass
228+
await server.stop()
229+
230+
# Actually start the server
231+
try:
232+
loop.run_until_complete(run(loop))
233+
except KeyboardInterrupt:
234+
logger.debug("Shutting Down")
235+
trigger.set()
236+
pass
237+

0 commit comments

Comments
 (0)