I've done this all at my own expense.
Please consider donating via...
https://www.paypal.com/donate/?hosted_button_id=THMF458QTBCAL
Important!: This project is all very new, experimental, lightly-tested, and subject to change.
I'm looking for serious contributors to...
1. Help tune the default device settings to exactly match how all three games handle a range of sunlight. Until I can do some testing, they are just guesses.
2. Add support via Single Analog Control Mode to their emulators.
3. Test and fix the code if needed.
4. Test the GBA link cable support.
5. Create a cool 3D-printable case.
6. You tell me!
...so please contact me if you are interested at TideGear @at@ gmail .dot. com !
Also contact me if you want to buy a pre-assembled device or kit!
Photos of a unfinished prototype: https://photos.app.goo.gl/ex75rCqATgqSrdCb6
A substitute solar sensor for playing the Boktai series (Boktai 1, 2, 3) on flash carts or emulators. Restores "Kojima's Intent" by using real sunlight (UV) instead of artificial light hacks.
Supported Games:
- Boktai 1: The Sun Is in Your Hand (8-bar gauge)
- Boktai 2: Solar Boy Django (10-bar gauge)
- Boktai 3: Sabata's Counterattack (10-bar gauge)
Recommended setup: Bluetooth Incremental Mode + mGBA libretro core (RetroArch)
- Build the hardware (see Hardware below)
- Flash the firmware (see Firmware Setup)
- Pair the device as a Bluetooth controller in your OS
- In mGBA (libretro), map L3 to solar sensor decrease and R3 to increase
- Power on: hold button 3 seconds
- Tap button to select game (BOKTAI 1, 2, or 3). (Optional) Tap to the DEBUG screen to view UV, UVI, compensated UVI, and battery values.
- Point sensor toward the sky — the device syncs the in-game meter automatically
- Power off: hold button 3 seconds
Important: Only wake the device once you are in-game. The controller inputs it sends (button presses, stick deflections) may cause unwanted behavior in menus or other apps. When possible, put the device to sleep (hold 3s) when you are not actively playing a Boktai game.
Important: Placing the sensor behind standard glass or plastic blocks UV light. Use the UV_ENCLOSURE_* settings in config.h to compensate for your enclosure window. Strongly UV-blocking materials can still reduce usable range. The default values will be (but not yet) set to adjust for the transmission loss of these boxes: https://a.co/d/092sVvLT
The Ojo del Sol can output bar values in three ways. Choose based on your setup:
Read the bar count from the OLED and enter it manually using Prof9's ROM hacks or your flash cart's input method.
The device appears as an Xbox Series X controller ("Ojo del Sol Sensor") and sends button/stick inputs to control the emulator's solar sensor.
Two BLE control modes (set via BLE_CONTROL_MODE in config.h):
| Mode | How it works | Emulator support |
|---|---|---|
| Incremental (default, mode 0) | Sends L3/R3 button presses to step the meter up/down | Works now with mGBA libretro core |
| Single Analog (mode 1) | Better for Ojo del Sol! Maps bar count to proportional deflection on one analog axis | Requires emulator support |
Current recommendation: Use Incremental Mode with the mGBA libretro core in RetroArch until Single Analog Mode is supported by emulators.
Emulator developers: See For Emulator Devs.md for the Single Analog Mode specification, band mapping tables, and pseudocode.
Status: Untested. This outputs a 4-bit bar count on GPIO pins for use with a physical GBA via link cable.
Requirements:
- Prof9's ROM hack from the gba-link-gpio branch: https://github.com/Prof9/Boktai-Solar-Sensor-Patches/tree/gba-link-gpio/Source
- Wire the Ojo del Sol to the Player 2 side of the link cable
Limitation: Boktai's normal link-cable modes (e.g., multiplayer) are not compatible with this and may never be. To use multiplayer alongside the Ojo del Sol, you'll need an emulator with link support (I've contacted Pizza Boy A Pro and Linkboy devs — TBD).
Components:
- Seeed XIAO ESP32S3 (built-in LiPo charging)
- Adafruit LTR390 UV Sensor (I2C)
- SSD1306 128x64 OLED Display (I2C)
- Tactile push button (power/game select)
- 3.7V LiPo battery (450mAh recommended)
- 2x 100K ohm resistors (for battery monitoring — optional but recommended)
| Component | Pin | XIAO ESP32S3 | Notes |
|---|---|---|---|
| OLED VIN | 3V3 | 3.3V power | |
| OLED GND | GND | ||
| LTR390 VIN | D3 (GPIO4) | Sensor power (GPIO-controlled) | |
| LTR390 GND | GND | ||
| OLED + LTR390 SDA | D4 (GPIO5) | I2C data (configurable via I2C_SDA_PIN) |
|
| OLED + LTR390 SCL | D5 (GPIO6) | I2C clock (configurable via I2C_SCL_PIN) |
|
| Button | Leg A | D0 (GPIO1) | Signal (internal pull-up) |
| Button | Leg B | GND | |
| Battery + | BAT+ (back pad) | Farthest from USB-C | |
| Battery - | BAT- (back pad) | Closest to USB-C |
The XIAO doesn't expose battery voltage directly. To enable battery percentage display, add a voltage divider:
BAT+ ----[100K]----+---- D1 (GPIO2)
|
[100K]
|
GND ---------------+
Battery sensing is disabled by default (BATTERY_SENSE_ENABLED = false). After wiring the divider, set BATTERY_SENSE_ENABLED = true in config.h to show battery data.
Calibration: If battery % is wrong at full charge, adjust VOLT_DIVIDER_MULT in config.h (increase if reading low, decrease if high).
Only needed if using GBA Link output mode. The GBA connects to the Player 1 (P1) side of a cheap GBA link cable. The Ojo del Sol connects to the Player 2 (P2) side via a Palmr breakout board.
Signal-to-GPIO mapping (what GBA signal each XIAO pin handles):
| GBA Signal | GBA Port Pin | XIAO Pin |
|---|---|---|
| SO | 2 | D10 (GPIO10) |
| SI | 3 | D9 (GPIO8) |
| SD | 4 | D8 (GPIO7) |
| SC | 5 | D7 (GPIO44) |
| GND | 6 | (leave unconnected) |
| VDD35 | 1 | (leave unconnected) |
Cheap cable crossover wiring:
Cheap GBA link cables have non-standard internal wiring (no mid-cable signal splitter). The cable crosses some lines, so the P2 breakout pad labels do not match the GBA signal names. Use the table below to determine which breakout pad to physically wire each XIAO pin to:
| XIAO Pin | GBA Signal | P1 Pin | Cable Routes To | P2 Breakout Pad Label |
|---|---|---|---|---|
| D10 | SO | Pin 2 | P2 Pin 3 | SI |
| D9 | SI | Pin 3 | P2 Pin 6 | GND (see note below) |
| D8 | SD | Pin 4 | P2 Pin 4 | SD |
| D7 | SC | Pin 5 | P2 Pin 5 | SC |
The SO-to-SI crossover (P1 Pin 2 → P2 Pin 3) is expected — one device's Serial Out arrives at the other's Serial In. SD and SC are straight-through.
Verify your specific cable's wiring with a multimeter continuity test. See this sheet for reference.
Important — Pin 6 "GND" pad on the breakout:
The cheap cable routes P1 Pin 3 (GBA's SI) to P2 Pin 6. The breakout board labels this pad "GND" because Pin 6 is normally the ground pin on a standard GBA port. However, the Palmr breakout board simply passes through the connector pin — it is not tied to an actual ground plane. It is safe to connect D9 here; the pad will carry the SI signal despite its label.
Notes:
- Keep all signals at 3.3V logic (the XIAO ESP32S3 is 3.3V native, so no level shifting is needed)
Install via Arduino Library Manager:
- Adafruit LTR390 Library
- Adafruit SSD1306
- Adafruit GFX Library
- Adafruit BusIO
- NimBLE-Arduino (by h2zero)
- Callback (by Tom Stewart)
Install manually (included in repo as ESP32-BLE-CompositeHID-master.zip, or download from GitHub):
- ESP32-BLE-CompositeHID: https://github.com/Mystfit/ESP32-BLE-CompositeHID
- Boards Manager URL:
https://raw.githubusercontent.com/espressif/arduino-esp32/gh-pages/package_esp32_index.json - Board package: esp32 by Espressif Systems
- Recommended version:
3.3.6 - Known issue:
3.3.7may crash during BLE startup with:assert failed: block_locate_free tlsf_control_functions.h:618If this happens, roll back to3.3.6in Boards Manager. - Board: XIAO_ESP32S3
- USB CDC On Boot: Enabled (for serial debugging)
When device is ON:
- Tap: Cycle screens (1 → 2 → 3 → DEBUG → 1...). Set
DEBUG_SCREEN_ENABLED = falsein config.h to remove the DEBUG screen. - Hold 3s: Power OFF (deep sleep, ~10µA)
- If screensaver is active, first tap wakes the screen
When waking from sleep:
- Hold 3s: Power ON
- Short press/tap: Immediately shows "Hold 3s to power on"
- If there is no button activity for 10 seconds, it returns to sleep
The device advertises as "Ojo del Sol Sensor" (configurable via BLE_DEVICE_NAME).
Incremental Mode specifics:
- The firmware tracks the in-game meter state and sends L3/R3 presses to sync it
- Performs a clamp+refill resync every
BLE_RESYNC_INTERVAL_MS(default 60s) - Press rate controlled by
BLE_BUTTONS_PER_SECOND(default 20) - For Boktai 1, mGBA uses 10 internal steps despite 8 visible bars — the firmware compensates (disable via
BLE_BOKTAI1_MGBA_10_STEP_WORKAROUND = falseif fixed) - Button remapping: Change
BLE_BUTTON_DECandBLE_BUTTON_INCin config.h to use different buttons (seeXboxGamepadDevice.hfor available constants)
Single Analog Mode specifics:
- Updates immediately on bar change (no rate throttling)
- Maps bar count to a proportional deflection on a configurable analog axis (default: Right X+)
- Optionally holds a configurable "meter unlock" button (default: R3) while the axis is active (
BLE_METER_UNLOCK_BUTTON_ENABLED). Single Analog mode uses midpoint band mapping, so even 0 bars sends a small deflection (not exact center). The button is released and re-pressed on every deflection change and periodically (BLE_METER_UNLOCK_REFRESH_MS, default 1000ms) to ensure apps/emulators always register it. - Axis and unlock button are configurable in config.h (
BLE_SINGLE_ANALOG_AXIS,BLE_METER_UNLOCK_BUTTON)
Pairing:
- Times out after
BLE_PAIRING_TIMEOUT_MS(default 60s) - Re-enters pairing if connection drops
- Pairing restarts on wake — after waking from sleep, the device re-advertises for pairing
- Bluetooth icon flashes while pairing, solid when connected
After SCREENSAVER_TIME minutes (default 3) of no button activity, the display shows a bouncing "Ojo del Sol" logo. Press any button to wake. Set SCREENSAVER_TIME = 0 to disable (but this affects OLED lifespan). The status row appears only when battery data is available and/or BLE is actively connected or pairing.
Bar thresholds are calculated from two values in config.h:
const bool AUTO_MODE = true;
const float AUTO_UV_MIN = 0.500; // UV Index for 1 bar
const float AUTO_UV_SATURATION = 6.000; // UV Index ceiling (output clamp)Bars are distributed evenly across this range for all games. AUTO_UV_SATURATION is a saturation ceiling, so the highest bar can begin before this value and then remains clamped at full above it. UV Index 6 is typical for a sunny day in temperate climates.
Set AUTO_MODE = false to use per-game threshold arrays (BOKTAI_1_UV[8], BOKTAI_2_UV[10], BOKTAI_3_UV[10]).
If the sensor is behind a transparent window, model that window as transmission loss:
const bool UV_ENCLOSURE_COMP_ENABLED = true;
const float UV_ENCLOSURE_TRANSMITTANCE = 0.42; // 42% UV passes through the window
const float UV_ENCLOSURE_UVI_OFFSET = 0.000; // Optional bias correctionFirmware applies compensation once after raw-to-UVI conversion:
corrected_uvi = max(0, (measured_uvi - offset) / transmittance)
Quick calibration:
- Temporarily set
UV_ENCLOSURE_COMP_ENABLED = falsewhile collecting calibration readings. - Take side-by-side readings at the same time/angle: one sensor open-air, one in-box.
- Compute
in_box_uvi / open_air_uviacross several samples. - Use the median ratio as
UV_ENCLOSURE_TRANSMITTANCE. - Re-enable compensation (
UV_ENCLOSURE_COMP_ENABLED = true).
Note: On the DEBUG screen, UVI is the measured (pre-compensation) value and UVI comp'ed is the post-compensation value.
If the bar display feels jumpy:
UVI_SMOOTHING_ENABLED: Applies exponential smoothing (default:false; settrueto smooth)UVI_SMOOTHING_ALPHA: Controls how much weight new readings get vs. the running average (default:0.5; higher = faster response, lower = smoother). SettingUVI_SMOOTHING_ALPHA = 1.0is the same asUVI_SMOOTHING_ENABLED = false.BAR_HYSTERESIS_ENABLED: Enables bar hysteresis (default:false). SettingBAR_HYSTERESIS_ENABLED = falseis the same asBAR_HYSTERESIS = 0.0.BAR_HYSTERESIS: Requires UV margin before changing bars (default:0.200; only used whenBAR_HYSTERESIS_ENABLED = true)
- Photodiode with 8-bit ADC
- Values inverted: 0xE8 = darkness, 0x50 = max gauge, 0x00 = extreme
- Accessed via GPIO at 0x80000C4-0x80000C8
- Reference sensitivity: 2300 counts/UVI at 18x gain, 400ms integration
- Formula (measured):
UVI = raw / 2300(at our settings: gain 18x, 20-bit/400ms, 500ms rate) - Formula (corrected, optional enclosure compensation):
UVI = max(0, (measured_uvi - offset) / transmittance) - Peak response: 300-350nm (UV-A/UV-B)
- Not inverted: higher values = more UV
- Sensor power can be GPIO-controlled (
SENSOR_POWER_PIN) to cut power during sleep - Alternatively, set
SENSOR_POWER_ENABLED = falsein config.h and wire LTR390 VIN to 3V3 instead of a GPIO pin - Battery sensing and low-voltage cutoff are disabled by default; enable after wiring and validating the battery divider (
BATTERY_SENSE_ENABLED,BATTERY_CUTOFF_ENABLED) - Battery sampling/cutoff checks run on a timed loop and are not dependent on UV "new data" events
- OLED uses Display OFF + Charge Pump OFF commands
- On wake, firmware explicitly re-enables the OLED charge pump/display before drawing
- Deep sleep current: ~10µA (varies with module pull-ups)
- The LTR390 breakout LED is hardwired to VIN — it turns off in sleep if you use GPIO power control
Most glass and many plastics block UV strongly (often 90%+). Compensation can correct scale loss, but it cannot recover signal if too little UV reaches the sensor. Prefer an open aperture, quartz glass, or UV-transparent acrylic.
- Enable
DEBUG_SERIAL = trueand check Serial Monitor (115200 baud)- At UVI 6, expect ~13800 raw counts (6 × 2300)
- If raw = 0, sensor may not be in UV mode
- Ensure sensor faces the sky (not blocked by hand/case)
- If using an enclosure window, verify
UV_ENCLOSURE_TRANSMITTANCEand use UV-transparent material - Indoor UV is near zero — test outside
You need the voltage divider circuit and BATTERY_SENSE_ENABLED = true. Keep battery sensing disabled until the divider is wired.
Adjust VOLT_DIVIDER_MULT in config.h (increase if reading low, decrease if high).
Check BATTERY_CUTOFF_ENABLED and VOLT_CUTOFF. Cutoff is disabled by default; only enable it after battery sensing is wired and validated.
- Hold button for full 3 seconds
- A short press should immediately show the "Hold 3s to power on" prompt
- After 10s of no activity, it returns to sleep
- If the OLED fails to initialize, the device enters deep sleep after about 2 seconds using the normal sleep path (same cleanup and button-release debounce as manual sleep). Check I2C wiring and
DISPLAY_I2C_ADDRin config.h (some modules use0x3Dinstead of0x3C)
If Serial Monitor shows:
assert failed: block_locate_free tlsf_control_functions.h:618
right after BLE/BTDM startup logs, this is a known board-package compatibility issue on some setups with esp32 core 3.3.7.
Fix: In Arduino IDE -> Boards Manager, install esp32 core 3.3.6 (or the last known-good version for your environment), then rebuild and flash.
- USB output for emulators
- Lunar Knights support?