AZIMUT Lamp : Connect to Where You Belong.

by etienne_makes in Circuits > Arduino

893 Views, 3 Favorites, 0 Comments

AZIMUT Lamp : Connect to Where You Belong.

vignette.png
PXL_20260124_142948437.jpg
PXL_20260204_224753138.jpg
azimut lamp
FV2JEQJMOKJY8K0.png

This is AZIMUT, a compass lamp that points toward any meaningful place on Earth. A mini web map connected to the lamp calculates the bearing from your current position to a chosen destination. When you rotate the lamp toward that azimuth, the light turns on. More than a lamp, it’s a discreet ritual—a reminder that everyone carries an internal 'North'—a place, a person, or an origin.


In my version, I use a piece of Paris sidewalk: a beautiful granite stone that shines under the light. This project is tailor-made as a gift for someone who lives far away from their beloved city. But the place can be a city, a childhood home, a mountain, a lighthouse, a friend, or any point you want to keep in mind. The lamp works like a compass. You place a personal object on it, choose a destination, calculate the azimuth, and rotate the base. When the object is aligned with the right direction, the light turns on.


The object on the lamp can be a stone, a shell, a key, a small souvenir, a piece of wood, or anything that connects you to the chosen direction. The important part is not the exact shape of my lamp. The important part is the system: turning geographic data into a daily, subtle gesture that reconnects you with a meaningful place.


In this Instructable, I will show you how to:


- build the electronics with an ESP32-C3 and an AS5600 magnetic angle sensor

- build the 3D printed rotating base

- assemble and calibrate the lamp

- use an optional NFC tag to open the digital map page

- choose a meaningful destination

- use a small HTML compass website to calculate the azimuth


You can adapt the size of the base, the top plate, and the arm to fit your own object and lamp design.

Supplies

ChatGPT Image 14 mai 2026, 15_26_24.png
ChatGPT Image 14 mai 2026, 15_26_08.png
supplies electro.png

This version uses a simple structure: seven 3D printed parts, a 10 mm aluminium U profiles, an ESP32-C3, an AS5600 angle sensor, a 1W LED with driver, and an NFC tag.

Electronics

  1. ESP32-C3 Mini or ESP32-C3 SuperMini
  2. AS5600 magnetic angle sensor module
  3. Diametrically magnetized round magnet
  4. LD06AJSA constant-current LED driver board
  5. 1W warm white LED, 3000K, 3.2–3.4V
  6. NFC tag or NFC sticker
  7. USB cable
  8. 5V USB power supply
  9. Thin wires
  10. Heat-shrink tubing

Lamp Structure

  1. one 10 × 10 × 10 mm aluminium U profile, 1 mm thick, about 500 mm long

3D Printed Parts

The lamp is assembled from seven 3D printed parts:

  1. Fixed base with space for the electronics
  2. Lazy susan rotating part
  3. Object support / totem holder
  4. First elbow connector for the aluminium U profiles
  5. Second elbow connector for the aluminium U profiles
  6. Compass pointer

No screws are required in this version. The parts are designed to fit together directly, with hot glue used where needed.

Tools

  1. 3D printer
  2. Soldering iron and solder
  3. Wire cutter / stripper
  4. Multimeter
  5. Computer with Arduino IDE
  6. Smartphone with NFC writing app
  7. Drill

Files

  1. STL files for the 7 printed parts
  2. Arduino code for the ESP32-C3
  3. HTML file for the azimuth calculator / map page


Safety Note

This project is powered by 5V USB. Do not connect it directly to mains voltage.

3D Print the Parts

PXL_20260514_082330827.jpg
PXL_20260514_082326327.jpg
PXL_20260514_082609986.jpg
PXL_20260514_083122384.jpg


Start by printing the seven structural parts of the lamp:

  1. the fixed base, which contains the electronics
  2. the lazy susan rotating part with rollers
  3. the object support in 2 parts
  4. the two elbow connectors for the aluminium U profiles
  5. the compass pointer


I printed them in PLA, with 30% infill.

The dimensions are not critical. You can adapt the size of the base, the top plate, and the arm to fit your own object and lamp design. The important part is to keep enough room in the fixed base for the ESP32-C3, the AS5600 module, the LED driver and the wiring.

Wire the Electronics

Azimut.png
PXL_20260514_082422942.jpg
PXL_20260514_082400330.jpg

The electronic system is simple: the AS5600 reads the rotation angle, the ESP32-C3 compares that angle with the target azimuth, and the LD06AJSA driver powers the 1W LED when the lamp is aligned.

The AS5600 communicates with the ESP32-C3 over I2C.

Follow the connection guide, and keep the same GPIO as the code will refer to them.


1. AS5600 TO ESP32-C3


ESP32-C3 3V3 -> AS5600 VCC

ESP32-C3 GND -> AS5600 GND

ESP32-C3 GPIO2 -> AS5600 SDA

ESP32-C3 GPIO10 -> AS5600 SCL


2. LD06AJSA LED DRIVER TO ESP32-C3


ESP32-C3 5V / VBUS -> LD06AJSA VIN+ / IN+

ESP32-C3 GND -> LD06AJSA VIN- / IN- / GND

ESP32-C3 GPIO5 -> LD06AJSA CE / EN / DIM


3. LED TO LD06AJSA DRIVER


LD06AJSA LED+ / OUT+ -> LED +

LD06AJSA LED- / OUT- -> LED -


IMPORTANT NOTES


GPIO5 does not power the LED.

GPIO5 only controls the CE / DIM input of the LD06AJSA driver.

The LED must be powered through the constant-current LED driver.

All grounds must be connected together:

Keep the wires short inside the base, but leave the LED wires long for now. You will need that extra length when you assemble the aluminium arm.

Solder carefully and insulate exposed joints with heat-shrink tubing and / or hot glue.


Before closing anything, test the system on the table:

  1. Plug the ESP32-C3 into USB.
  2. Upload this simple AS5600 test code with Arduino IDE
#include <Wire.h>

// Pins I2C pour ESP32-C3 Mini / SuperMini
#define SDA_PIN 2
#define SCL_PIN 10

// Adresse I2C du AS5600
#define AS5600_ADDR 0x36

// Registres angle brut AS5600
#define RAW_ANGLE_HIGH 0x0C
#define RAW_ANGLE_LOW 0x0D

uint16_t readAS5600RawAngle() {
Wire.beginTransmission(AS5600_ADDR);
Wire.write(RAW_ANGLE_HIGH);
uint8_t error = Wire.endTransmission(false); // repeated start

if (error != 0) {
Serial.print("I2C error: ");
Serial.println(error);
return 0xFFFF;
}

Wire.requestFrom(AS5600_ADDR, 2);

if (Wire.available() < 2) {
Serial.println("AS5600 not responding");
return 0xFFFF;
}

uint8_t highByte = Wire.read();
uint8_t lowByte = Wire.read();

// Le AS5600 donne un angle sur 12 bits : 0 à 4095
uint16_t rawAngle = ((highByte & 0x0F) << 8) | lowByte;

return rawAngle;
}

void setup() {
Serial.begin(115200);
delay(1000);

Serial.println();
Serial.println("AS5600 test");
Serial.println("Starting I2C...");

Wire.begin(SDA_PIN, SCL_PIN);
Wire.setClock(100000);

// Test présence du capteur
Wire.beginTransmission(AS5600_ADDR);
uint8_t error = Wire.endTransmission();

if (error == 0) {
Serial.println("AS5600 found at address 0x36");
} else {
Serial.print("AS5600 not found. I2C error: ");
Serial.println(error);
}
}

void loop() {
uint16_t rawAngle = readAS5600RawAngle();

if (rawAngle != 0xFFFF) {
float angleDeg = rawAngle * 360.0 / 4096.0;

Serial.print("Raw angle: ");
Serial.print(rawAngle);

Serial.print(" / Angle: ");
Serial.print(angleDeg, 2);

Serial.println(" deg");
}

delay(200);
}
  1. Open the Serial Monitor.
  2. Rotate the magnet above the sensor.
  3. Check that the angle value changes smoothly.
  4. Upload this new test code.
#include <Wire.h>

// ==================================================
// PINS
// ==================================================

#define SDA_PIN 2
#define SCL_PIN 10
#define LED_CE_PIN 5

// ==================================================
// AS5600
// ==================================================

#define AS5600_ADDR 0x36
#define RAW_ANGLE_HIGH 0x0C
#define RAW_ANGLE_LOW 0x0D

// ==================================================
// ANGLE RANGE FOR LED ON
// ==================================================

// Change these values for your test.
// The LED will be fully ON only between these two angles.
float LED_ON_MIN_DEG = 80.0;
float LED_ON_MAX_DEG = 120.0;

// ==================================================
// AS5600 READ
// ==================================================

uint16_t readAS5600RawAngle() {
Wire.beginTransmission(AS5600_ADDR);
Wire.write(RAW_ANGLE_HIGH);

uint8_t error = Wire.endTransmission(false);

if (error != 0) {
return 0xFFFF;
}

Wire.requestFrom(AS5600_ADDR, 2);

if (Wire.available() < 2) {
return 0xFFFF;
}

uint8_t highByte = Wire.read();
uint8_t lowByte = Wire.read();

return ((highByte & 0x0F) << 8) | lowByte;
}

float readAS5600AngleDeg() {
uint16_t rawAngle = readAS5600RawAngle();

if (rawAngle == 0xFFFF) {
return -1.0;
}

return rawAngle * 360.0 / 4096.0;
}

// Handles both normal ranges and ranges crossing 0°.
// Example normal: 80° to 120°
// Example crossing zero: 350° to 10°
bool angleIsInRange(float angleDeg, float minDeg, float maxDeg) {
if (minDeg <= maxDeg) {
return angleDeg >= minDeg && angleDeg <= maxDeg;
}

return angleDeg >= minDeg || angleDeg <= maxDeg;
}

// ==================================================
// SETUP
// ==================================================

void setup() {
Serial.begin(115200);
delay(1000);

Serial.println();
Serial.println("AZIMUT AS5600 + LD06AJSA current adjustment test");
Serial.println("------------------------------------------------");
Serial.println("CE is either fully HIGH or fully LOW.");
Serial.println("No PWM is used in this test.");
Serial.println("Use this code to adjust the driver current screw.");
Serial.println();

pinMode(LED_CE_PIN, OUTPUT);
digitalWrite(LED_CE_PIN, LOW);

Wire.begin(SDA_PIN, SCL_PIN);
Wire.setClock(100000);

Wire.beginTransmission(AS5600_ADDR);
uint8_t error = Wire.endTransmission();

if (error == 0) {
Serial.println("AS5600 found at address 0x36");
} else {
Serial.print("AS5600 not found. I2C error: ");
Serial.println(error);
}

Serial.print("LED full ON range: ");
Serial.print(LED_ON_MIN_DEG);
Serial.print(" deg to ");
Serial.print(LED_ON_MAX_DEG);
Serial.println(" deg");
Serial.println();

Serial.println("Measure current in series with the LED.");
Serial.println("Target: 200-250 mA recommended, 300 mA maximum.");
Serial.println();
}

// ==================================================
// LOOP
// ==================================================

void loop() {
float angleDeg = readAS5600AngleDeg();

if (angleDeg < 0) {
digitalWrite(LED_CE_PIN, LOW);
Serial.println("AS5600 read error | LED OFF");
delay(300);
return;
}

bool aligned = angleIsInRange(angleDeg, LED_ON_MIN_DEG, LED_ON_MAX_DEG);

if (aligned) {
digitalWrite(LED_CE_PIN, HIGH); // Full driver output
} else {
digitalWrite(LED_CE_PIN, LOW);
}

Serial.print("Angle: ");
Serial.print(angleDeg, 2);
Serial.print(" deg | LED: ");
Serial.println(aligned ? "FULL ON - adjust current now" : "OFF");

delay(100);
}
  1. Rotate the magnet above the sensor until the LED lights up.
  2. When the LED is on, measure the LED current with a multimeter in current mode, connected in series with the LED. Adjust the small potentiometer on the LD06AJSA driver until the current is around 200–250 mA. Do not exceed 300 mA.
  3. Upload the final code.
#include <WiFi.h>
#include <WebServer.h>
#include <Wire.h>
#include <Preferences.h>

#define SDA_PIN 2
#define SCL_PIN 10
#define LED_CE_PIN 5

#define AS5600_ADDR 0x36
#define AS5600_RAW_ANGLE_H 0x0C

const char* AP_SSID = "AZIMUT-LAMP";
const char* AP_PASS = ""; // Open Wi-Fi, no password

WebServer server(80);
Preferences prefs;

const int PWM_FREQ = 1000;
const int PWM_RES = 8;
const int PWM_MAX = 255;

const float FULL_ON_DEG = 3.0;
const float FADE_OUT_DEG = 35.0;

float targetAngleDeg = 0.0;
bool calibrated = false;

float normalizeDeg(float a) {
while (a < 0) a += 360.0;
while (a >= 360.0) a -= 360.0;
return a;
}

float angleDiffDeg(float a, float b) {
float d = a - b;
while (d > 180.0) d -= 360.0;
while (d < -180.0) d += 360.0;
return d;
}

bool as5600Present() {
Wire.beginTransmission(AS5600_ADDR);
return Wire.endTransmission() == 0;
}

float readAS5600Deg() {
Wire.beginTransmission(AS5600_ADDR);
Wire.write(AS5600_RAW_ANGLE_H);

if (Wire.endTransmission(false) != 0) return -1;

Wire.requestFrom(AS5600_ADDR, (uint8_t)2);

if (Wire.available() < 2) return -1;

uint8_t high = Wire.read();
uint8_t low = Wire.read();

uint16_t raw = ((high & 0x0F) << 8) | low;
return raw * 360.0 / 4096.0;
}

void setLed(int pwm) {
pwm = constrain(pwm, 0, PWM_MAX);
ledcWrite(LED_CE_PIN, pwm);
}

int computeLedPWM(float currentDeg) {
if (!calibrated) return 0;

float diff = abs(angleDiffDeg(currentDeg, targetAngleDeg));

if (diff <= FULL_ON_DEG) return PWM_MAX;
if (diff >= FADE_OUT_DEG) return 0;

float t = (FADE_OUT_DEG - diff) / (FADE_OUT_DEG - FULL_ON_DEG);
t = t * t * (3.0 - 2.0 * t);

return round(t * PWM_MAX);
}

String pageStart(String title) {
String html = "";
html += "<!DOCTYPE html><html lang='en'><head>";
html += "<meta charset='UTF-8'>";
html += "<meta name='viewport' content='width=device-width, initial-scale=1.0'>";
html += "<title>" + title + "</title>";
html += "<style>";
html += "body{margin:0;background:#111;color:#fff;font-family:-apple-system,BlinkMacSystemFont,Segoe UI,Roboto,sans-serif;text-align:center;padding:42px 24px;}";
html += "h1{font-weight:300;letter-spacing:.12em;text-transform:uppercase;margin:0 0 20px;}";
html += "p{color:#aaa;line-height:1.45;}";
html += ".box{border:1px solid rgba(255,255,255,.18);padding:18px;margin:22px auto;max-width:380px;}";
html += ".angle{font-size:3.4rem;font-weight:200;margin:24px 0;}";
html += "a{color:#111;background:#ddd;text-decoration:none;padding:14px 20px;display:inline-block;margin:12px 6px;text-transform:uppercase;font-weight:700;letter-spacing:.1em;}";
html += "</style>";
html += "</head><body>";
return html;
}

String pageEnd() {
return "</body></html>";
}

void handleRoot() {
float current = readAS5600Deg();

String html = pageStart("Azimut Lamp");

html += "<h1>Azimut Lamp</h1>";
html += "<div class='box'>";

html += "<p><strong>Wi-Fi:</strong> " + String(AP_SSID) + "</p>";
html += "<p><strong>Password:</strong> none</p>";
html += "<p><strong>IP:</strong> " + WiFi.softAPIP().toString() + "</p>";

html += "<p><strong>AS5600:</strong> ";
html += as5600Present() ? "detected" : "not detected";
html += "</p>";

html += "<p><strong>Calibrated:</strong> ";
html += calibrated ? "yes" : "no";
html += "</p>";

html += "<p><strong>Stored target:</strong> ";
html += String(targetAngleDeg, 2);
html += "°</p>";

html += "<p><strong>Current angle:</strong> ";
if (current < 0) html += "unavailable";
else html += String(current, 2) + "°";
html += "</p>";

html += "</div>";
html += "<a href='/calibrate'>Save current lamp position</a>";
html += "<a href='/reset'>Reset</a>";

html += pageEnd();

server.send(200, "text/html", html);
}

void handleCalibrate() {
float angle = readAS5600Deg();

if (angle < 0) {
String html = pageStart("AS5600 Error");

html += "<h1>AS5600 not detected</h1>";
html += "<div class='box'>";
html += "<p>The lamp cannot read the AS5600 sensor.</p>";
html += "<p>Expected wiring:</p>";
html += "<p>SDA → GPIO2<br>SCL → GPIO10<br>LED CE/PWM → GPIO5</p>";
html += "</div>";
html += "<a href='/'>Back</a>";

html += pageEnd();

server.send(500, "text/html", html);
return;
}

targetAngleDeg = normalizeDeg(angle);
calibrated = true;

prefs.putBool("calibrated", calibrated);
prefs.putFloat("target", targetAngleDeg);

String html = pageStart("Azimut Saved");

html += "<h1>Azimut saved</h1>";
html += "<p>Lamp position memorized.</p>";
html += "<div class='angle'>";
html += String(targetAngleDeg, 2);
html += "°</div>";
html += "<p>You can now return to the lamp.</p>";
html += "<a href='/'>Status</a>";

html += pageEnd();

server.send(200, "text/html", html);

Serial.println("CALIBRATION SAVED");
Serial.print("Target angle: ");
Serial.println(targetAngleDeg, 2);
}

void handleReset() {
calibrated = false;
targetAngleDeg = 0.0;

prefs.putBool("calibrated", calibrated);
prefs.putFloat("target", targetAngleDeg);

setLed(0);

String html = pageStart("Azimut Reset");
html += "<h1>Reset done</h1>";
html += "<p>Stored azimut has been cleared.</p>";
html += "<a href='/'>Back</a>";
html += pageEnd();

server.send(200, "text/html", html);
}

void handleStatus() {
float current = readAS5600Deg();

String json = "{";
json += "\"as5600\":";
json += as5600Present() ? "true" : "false";
json += ",";
json += "\"calibrated\":";
json += calibrated ? "true" : "false";
json += ",";
json += "\"target\":";
json += String(targetAngleDeg, 2);
json += ",";
json += "\"current\":";

if (current < 0) json += "null";
else json += String(current, 2);

json += "}";

server.send(200, "application/json", json);
}

void setup() {
Serial.begin(115200);
delay(1200);

Serial.println();
Serial.println("=========================");
Serial.println("AZIMUT LAMP START");
Serial.println("=========================");

pinMode(LED_CE_PIN, OUTPUT);
digitalWrite(LED_CE_PIN, LOW);

Wire.begin(SDA_PIN, SCL_PIN);
Wire.setClock(100000);
delay(300);

Serial.print("SDA pin: GPIO");
Serial.println(SDA_PIN);

Serial.print("SCL pin: GPIO");
Serial.println(SCL_PIN);

Serial.print("LED CE/PWM pin: GPIO");
Serial.println(LED_CE_PIN);

Serial.print("AS5600: ");
Serial.println(as5600Present() ? "FOUND at 0x36" : "NOT FOUND");

ledcAttach(LED_CE_PIN, PWM_FREQ, PWM_RES);
setLed(0);

prefs.begin("azimut", false);

calibrated = prefs.getBool("calibrated", false);
targetAngleDeg = prefs.getFloat("target", 0.0);

Serial.print("Calibrated: ");
Serial.println(calibrated ? "YES" : "NO");

Serial.print("Stored target: ");
Serial.print(targetAngleDeg, 2);
Serial.println("°");

WiFi.mode(WIFI_AP);
WiFi.softAP(AP_SSID);

Serial.print("Wi-Fi AP: ");
Serial.println(AP_SSID);

Serial.println("Password: none");

Serial.print("IP: ");
Serial.println(WiFi.softAPIP());

server.on("/", HTTP_GET, handleRoot);
server.on("/calibrate", HTTP_GET, handleCalibrate);
server.on("/reset", HTTP_GET, handleReset);
server.on("/status", HTTP_GET, handleStatus);

server.begin();

Serial.println("Web server started.");
Serial.println("Open: http://192.168.4.1/");
}

void loop() {
server.handleClient();

float currentDeg = readAS5600Deg();

if (currentDeg < 0) {
setLed(0);
return;
}

int pwm = computeLedPWM(currentDeg);
setLed(pwm);

static unsigned long lastPrint = 0;

if (millis() - lastPrint > 500) {
lastPrint = millis();

Serial.print("Current: ");
Serial.print(currentDeg, 2);
Serial.print("° | Target: ");
Serial.print(targetAngleDeg, 2);
Serial.print("° | Diff: ");
Serial.print(angleDiffDeg(currentDeg, targetAngleDeg), 2);
Serial.print("° | PWM: ");
Serial.print(pwm);
Serial.print(" | AS5600: ");
Serial.println(as5600Present() ? "OK" : "NO");
}
}


Do not run the 1W LED for long without a heatsink.

Insert the Electronics Into the Base

PXL_20260514_082727306.jpg
PXL_20260514_082513303.jpg

Once the wiring has been tested, place the electronics inside the fixed base.

The base should contain :

  1. the ESP32-C3 placed in its slot
  2. the AS5600 sensor module in the center
  3. the LD06AJSA LED driver on the side.

The magnet is held on the top part of the lazy Susan. It should be centered above the AS5600 chip and should rotate with the lazy susan part. Make sure you leave a small air gap between the magnet and the sensor. It should not rub.

Use hot glue to hold the boards and magnet in place once you are sure everything works.

Drill a hole to route the LED wires through, then assemble all the parts. Add either ball bearings or the 3D-printed cones to the lazy Susan mechanism.

Leave the LED wires long.

Assemble the Lamp

PXL_20260514_082748742.jpg
PXL_20260514_082818617.jpg
PXL_20260514_082941568.jpg
PXL_20260514_083002779.jpg
PXL_20260514_083038994.jpg
PXL_20260514_083111324.jpg
PXL_20260514_083225079.jpg
PXL 20260514 083340516 TS

The lamp arm is made from a 10 mm aluminium U profile.

Print the 45° cutting jig and the small pass-through forming tool. Use PLA at 100% infill.

Cut the profile into three segments according to the proportions you want for your lamp. The exact dimensions are up to you. The arm only needs to hold the LED above the object and hide and guide the LED wires.

In my version, I cut the profile into three parts: one 25 cm segment, one 7 cm segment, and one 10 cm segment. Then I cut the ends at 45° with the printed jig so the segments could form a clean angled arm with the 3D printed elbow connectors.

Push the U-profile slowly through the forming tool. You'll feel resistance. Use steady hammer taps (not violent). The two sides will gradually close, forming a triangular section.


General method:


1. Mark the length of each segment.

2. Mark the 45° cuts.

3. Cut the aluminium slowly with a hacksaw, rotary tool, or metal saw.

4. Deburr the edges with sandpaper or a small file.

5. Push each aluminium segment through the tool with a hammer to close the two sides and form a triangular profile.

6. Test the fit in the 3D printed elbow connectors.

7. Pass the LED wires through the aluminium profile before final assembly, if your design requires it.

Set Up the Mini Compass Website and NFC Tag

Screenshot_20260514-193936.png
Screenshot_20260514-193959.png
Screenshot_20260514-194007.png
PXL_20260517_144924293.jpg
azimut app

The mini website is the mapping layer of the project.


The physical lamp reveals one direction. The website explains where that direction comes from.


The HTML page lets you:


1. geolocate your current position

2. choose a destination on a map

3. calculate the bearing and display the target angle

4. connect to the Azimut Wi-Fi network created by the lamp

5. lock the current physical orientation into the lamp’s processor


Here is the html code :

<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no" />
<title>Azimut</title>

<link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css" />

<style>
:root {
--bg: #151517;
--text: #f1f1f1;
--muted: #8c8f94;
--line: rgba(255,255,255,0.18);
}

* {
box-sizing: border-box;
-webkit-tap-highlight-color: transparent;
user-select: none;
}

body {
margin: 0;
height: 100vh;
background: var(--bg);
color: var(--text);
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
overflow: hidden;
}

.screen {
display: none;
height: 100vh;
width: 100vw;
}

.screen.active {
display: flex;
}

#startScreen {
align-items: center;
justify-content: center;
padding: 28px;
text-align: center;
flex-direction: column;
gap: 18px;
}

h1 {
font-size: 1.8rem;
font-weight: 300;
letter-spacing: 0.12em;
text-transform: uppercase;
margin: 0;
}

p {
color: var(--muted);
line-height: 1.45;
margin: 0;
max-width: 340px;
}

button {
border: 1px solid var(--line);
background: linear-gradient(145deg, #d8d8d8, #8a8f8f);
color: #111;
padding: 15px 22px;
border-radius: 2px;
font-size: 0.82rem;
font-weight: 700;
letter-spacing: 0.12em;
text-transform: uppercase;
cursor: pointer;
}

button:disabled {
opacity: 0.35;
cursor: default;
}

button.secondary {
background: transparent;
color: var(--text);
}

#mapScreen {
flex-direction: column;
height: 100vh;
}

#map {
height: 66vh;
width: 100%;
background: #111;
flex: none;
}

.bottomPanel {
background: rgba(21,21,23,0.96);
border-top: 1px solid var(--line);
padding: 12px 16px 18px;
padding-bottom: max(18px, env(safe-area-inset-bottom));
display: flex;
flex-direction: column;
gap: 10px;
max-height: 34vh;
overflow-y: auto;
}

.small {
font-size: 0.78rem;
color: var(--muted);
line-height: 1.35;
}

.coords {
font-size: 0.86rem;
color: var(--text);
font-variant-numeric: tabular-nums;
line-height: 1.35;
}

#compassScreen {
align-items: center;
justify-content: center;
flex-direction: column;
gap: 22px;
padding: 24px;
text-align: center;
}

.instrument {
position: relative;
width: 340px;
height: 340px;
max-width: 88vw;
max-height: 88vw;
border-radius: 50%;
background: radial-gradient(circle at 30% 30%, #2b2b2e, #111113);
box-shadow:
24px 24px 50px #09090a,
-10px -10px 28px #28282b,
inset 1px 1px 2px rgba(255,255,255,0.1);
border: 8px solid #1e1e20;
display: flex;
align-items: center;
justify-content: center;
}

#needleWrapper {
position: absolute;
width: 100%;
height: 100%;
transform: rotate(0deg);
transition: transform 0.12s linear;
}

.needle {
position: absolute;
top: 50%;
left: 50%;
width: 24px;
height: 162px;
transform: translate(-50%, -100%);
background: linear-gradient(to top, rgba(255,255,255,0.95), transparent);
clip-path: polygon(50% 0%, 0% 100%, 100% 100%);
filter: blur(0.5px);
}

.hub {
position: relative;
z-index: 2;
width: 194px;
height: 194px;
max-width: 52vw;
max-height: 52vw;
border-radius: 50%;
background: #171719;
display: flex;
align-items: center;
justify-content: center;
flex-direction: column;
border: 1px solid #3a3f3f;
box-shadow:
inset 6px 6px 12px #0d0d0e,
inset -6px -6px 12px #27272a;
}

#bearing {
font-size: 3.6rem;
font-weight: 200;
font-variant-numeric: tabular-nums;
}

.deg {
font-size: 1.3rem;
color: var(--muted);
vertical-align: super;
}

#status {
margin-top: 10px;
font-size: 0.68rem;
letter-spacing: 0.22em;
color: var(--muted);
text-transform: uppercase;
}

#distance {
margin-top: 6px;
font-size: 0.78rem;
color: #5e6268;
}

.actions {
width: min(340px, 100%);
display: flex;
flex-direction: column;
gap: 10px;
}

$1

.leaflet-container {
background: #f2f2ef;
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
}

.leaflet-control-attribution {
font-size: 9px;
background: rgba(255,255,255,0.72) !important;
}
</style>
</head>

<body>
<section id="startScreen" class="screen active">
<h1>Azimut</h1>
<p>Choose a destination. The app will show you which direction to point the lamp, then save that physical position into the lamp.</p>
<button onclick="start()">Start</button>
</section>

<section id="mapScreen" class="screen">
<div id="map"></div>
<div class="bottomPanel">
<div class="small">Tap the map to place the Azimut destination.</div>
<div id="targetInfo" class="coords">No destination selected</div>
<button id="confirmTargetBtn" onclick="confirmTarget()" disabled>Confirm destination</button>
</div>
</section>

<section id="compassScreen" class="screen">
<div class="instrument">
<div id="needleWrapper">
<div class="needle"></div>
</div>
<div class="hub">
<div id="bearing">--<span class="deg">°</span></div>
<div id="status">Waiting</div>
<div id="distance">-- km</div>
</div>
</div>

<div class="actions">
<button onclick="calibrateLamp()">Lamp is aligned — save position</button>
<button class="secondary" onclick="backToMap()">Change destination</button>
<div id="sendStatus"></div>
</div>
</section>

<script src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js"></script>

<script>
const ESP32_CALIBRATE_URL = "http://192.168.4.1/calibrate";

let userPos = null;
let targetPos = null;
let targetBearing = 0;
let targetDistance = 0;

let map = null;
let marker = null;
let smoothRotation = 0;
let lastStableHeading = null;
let lastVibrationMs = 0;

const HEADING_SMOOTHING = 0.06;
const HEADING_DEADZONE_DEG = 1.2;
const AXIS_FOUND_DEG = 4;

function show(id) {
document.querySelectorAll(".screen").forEach(screen => screen.classList.remove("active"));
document.getElementById(id).classList.add("active");
}

function start() {
navigator.geolocation.getCurrentPosition(
position => {
userPos = {
lat: position.coords.latitude,
lon: position.coords.longitude
};

show("mapScreen");
initMap();
},
() => {
alert("Location unavailable. Please allow location access and make sure this page is served over HTTPS.");
},
{
enableHighAccuracy: true,
timeout: 12000,
maximumAge: 10000
}
);
}

function initMap() {
if (map) {
setTimeout(() => map.invalidateSize(), 200);
return;
}

map = L.map("map", {
zoomControl: false
}).setView([userPos.lat, userPos.lon], 4);

L.tileLayer("https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png", {
maxZoom: 18,
attribution: "&copy; OpenStreetMap"
}).addTo(map);

L.circleMarker([userPos.lat, userPos.lon], {
radius: 7,
color: "#fff",
weight: 2,
fillColor: "#fff",
fillOpacity: 0.7
}).addTo(map);

map.on("click", event => {
targetPos = {
lat: event.latlng.lat,
lon: event.latlng.lng
};

if (marker) marker.remove();

marker = L.marker([targetPos.lat, targetPos.lon]).addTo(map);

targetBearing = calcBearing(userPos.lat, userPos.lon, targetPos.lat, targetPos.lon);
targetDistance = calcDistance(userPos.lat, userPos.lon, targetPos.lat, targetPos.lon);

document.getElementById("targetInfo").innerText =
`Destination: ${targetPos.lat.toFixed(5)}, ${targetPos.lon.toFixed(5)} · ${Math.round(targetDistance).toLocaleString("en-US")} km · ${Math.round(targetBearing)}°`;

document.getElementById("confirmTargetBtn").disabled = false;
});
}

function confirmTarget() {
if (!targetPos) return;

document.getElementById("bearing").innerHTML =
Math.round(targetBearing) + '<span class="deg">°</span>';

document.getElementById("distance").innerText =
Math.round(targetDistance).toLocaleString("en-US") + " km";

document.getElementById("status").innerText = "Point the lamp";
document.getElementById("sendStatus").innerText = "";

show("compassScreen");

if (typeof DeviceOrientationEvent !== "undefined" && typeof DeviceOrientationEvent.requestPermission === "function") {
DeviceOrientationEvent.requestPermission()
.then(response => {
if (response === "granted") startCompassListeners();
else document.getElementById("status").innerText = "Motion access denied";
})
.catch(() => {
document.getElementById("status").innerText = "Motion access unavailable";
});
} else {
startCompassListeners();
}
}

function startCompassListeners() {
window.removeEventListener("deviceorientationabsolute", updateCompass, true);
window.removeEventListener("deviceorientation", updateCompass, true);
window.addEventListener("deviceorientationabsolute", updateCompass, true);
window.addEventListener("deviceorientation", updateCompass, true);
}

function backToMap() {
show("mapScreen");
setTimeout(() => map.invalidateSize(), 200);
}

function updateCompass(event) {
let heading = null;

if (typeof event.webkitCompassHeading === "number") {
heading = event.webkitCompassHeading;
} else if (typeof event.alpha === "number") {
heading = 360 - event.alpha;
}

if (heading === null) return;

if (lastStableHeading === null) {
lastStableHeading = heading;
}

const headingMove = angleDiff(heading, lastStableHeading);
if (Math.abs(headingMove) > HEADING_DEADZONE_DEG) {
lastStableHeading += headingMove * HEADING_SMOOTHING;
lastStableHeading = normalizeAngle(lastStableHeading);
}

const desiredRotation = angleDiff(targetBearing, lastStableHeading);
const diff = angleDiff(desiredRotation, smoothRotation);

if (Math.abs(diff) > 0.4) {
smoothRotation += diff * 0.08;
}

document.getElementById("needleWrapper").style.transform =
`rotate(${smoothRotation}deg)`;

const deltaToTarget = angleDiff(targetBearing, lastStableHeading);
const absDelta = Math.abs(deltaToTarget);
const needle = document.querySelector(".needle");
const now = Date.now();

if (absDelta <= AXIS_FOUND_DEG) {
document.getElementById("status").innerText = "Axis found";
needle.style.filter = "drop-shadow(0 0 22px white)";

if (navigator.vibrate && now - lastVibrationMs > 1200) {
navigator.vibrate(30);
lastVibrationMs = now;
}
} else if (deltaToTarget > 0) {
document.getElementById("status").innerText = "Turn →";
needle.style.filter = "blur(0.5px)";
} else {
document.getElementById("status").innerText = "← Turn";
needle.style.filter = "blur(0.5px)";
}
}

function calibrateLamp() {
document.getElementById("sendStatus").innerText =
"Opening the lamp calibration page. Make sure your phone is connected to the AZIMUT-LAMP Wi-Fi network.";

window.location.href = ESP32_CALIBRATE_URL;
}

function calcBearing(la1, lo1, la2, lo2) {
const p1 = la1 * Math.PI / 180;
const p2 = la2 * Math.PI / 180;
const dL = (lo2 - lo1) * Math.PI / 180;

const y = Math.sin(dL) * Math.cos(p2);
const x =
Math.cos(p1) * Math.sin(p2) -
Math.sin(p1) * Math.cos(p2) * Math.cos(dL);

return (Math.atan2(y, x) * 180 / Math.PI + 360) % 360;
}

function calcDistance(la1, lo1, la2, lo2) {
const R = 6371;
const dLat = (la2 - la1) * Math.PI / 180;
const dLon = (lo2 - lo1) * Math.PI / 180;

const a =
Math.sin(dLat / 2) ** 2 +
Math.cos(la1 * Math.PI / 180) *
Math.cos(la2 * Math.PI / 180) *
Math.sin(dLon / 2) ** 2;

return R * 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a));
}

function normalizeAngle(angle) {
return (angle + 360) % 360;
}

function angleDiff(a, b) {
return ((a - b + 540) % 360) - 180;
}
</script>
</body>
</html>

Feel free to adapt the code. Once the page is ready, host it online or you can use mine (https://etiennedc-design.github.io/boussole/). A static HTML page is enough. You can use GitHub Pages, Netlify, Vercel, or any simple static hosting service.


Stick the NFC tag on top of the base, then program it with the page URL.


1. Open an NFC writing app on your phone.

2. Choose “Write URL”.

3. Paste the URL of your azimuth page.

4. Write it to the NFC sticker.

5. Test it by tapping your phone on the tag.

Calibrate and Use the Lamp

azimut lamp

Now you can use the lamp.


1. Place your object on the support.

2. Tap the NFC tag to open the map page.

3. Allow geolocation access.

4. Tap the map to choose your destination.

5. Confirm the destination.

6. The web app displays the bearing.

7. Slowly rotate the lamp until the pointer matches the direction shown on the phone.

8. Press “Lamp is aligned — save position”.

9. The ESP32 stores this AS5600 angle as the target position.

10. From now on, the LED fades in when the lamp is rotated back toward that direction.


That moment is the whole point of the object: a small daily gesture that turns a meaningful place into a physical direction.