Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
46 changes: 40 additions & 6 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
@@ -1,24 +1,58 @@
name: build and release

on:
release:
types: [ created, published ]

# Exactly one build per release, driven by the tag push.
#
# There is deliberately no `release:` trigger. Creating a release on a new
# tag also pushes that tag, so having both fires two runs for one release —
# and `types: [created, published]` fired twice again on its own, because
# publishing a non-draft release emits both events.
#
# Pushing a tag builds every platform and attaches the artifacts to a *draft*
# release. Publishing that draft does not rebuild: nothing here listens for
# it, and the binaries are already attached.
#
# Note `release: created` could not have covered the draft case anyway —
# GitHub does not trigger workflows for the created/edited/deleted activity
# types on draft releases, and a draft has no git tag until it is published.
push:
tags:
- 'v*'

# Manual builds for sharing test binaries; uploaded as workflow artifacts
# rather than to a release.
workflow_dispatch:

# One run per tag. Queues rather than cancels: these runs upload release
# assets, so killing one mid-upload would leave the release incomplete.
concurrency:
group: release-${{ github.ref_name }}
cancel-in-progress: false

permissions:
contents: write

env:
CARGO_TERM_COLOR: always

jobs:
# Create the draft release once, before the matrix fans out. Four parallel
# jobs each calling action-gh-release on a tag with no existing release would
# race to create it; with this job they only ever add assets to it.
create-release:
name: create draft release
if: startsWith(github.ref, 'refs/tags/')
runs-on: ubuntu-latest
steps:
- uses: softprops/action-gh-release@v3
with:
draft: true

build:
name: build (${{ matrix.platform.label }}-${{ matrix.platform.arch }})
needs: [create-release]
# Still build on workflow_dispatch, where create-release is skipped.
if: ${{ !cancelled() && needs.create-release.result != 'failure' }}
runs-on: ${{ matrix.platform.runner }}
strategy:
fail-fast: false
Expand Down Expand Up @@ -115,7 +149,7 @@ jobs:
if: runner.os == 'macOS'
shell: bash
env:
APP_VERSION: ${{ github.event.release.tag_name || github.ref_name }}
APP_VERSION: ${{ github.ref_name }}
run: |
set -euo pipefail
rm -rf dist && mkdir -p dist
Expand Down Expand Up @@ -152,15 +186,15 @@ jobs:
(Get-FileHash "dist/${{ matrix.platform.archive }}" -Algorithm SHA256).Hash.ToLower() | Set-Content "dist/${{ matrix.platform.archive }}.sha256"

- name: Release artifacts (non-mac)
if: github.event_name == 'release' && runner.os != 'macOS'
if: startsWith(github.ref, 'refs/tags/') && runner.os != 'macOS'
uses: softprops/action-gh-release@v3
with:
files: |
dist/${{ matrix.platform.archive }}
dist/${{ matrix.platform.archive }}.sha256

- name: Release artifacts (mac)
if: github.event_name == 'release' && runner.os == 'macOS'
if: startsWith(github.ref, 'refs/tags/') && runner.os == 'macOS'
uses: softprops/action-gh-release@v3
with:
files: |
Expand Down
131 changes: 128 additions & 3 deletions docs/BT_PROTOCOL.md
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,34 @@ a valid checksum, the device responds with error code `0x06` in the ACK.

Implementation: `el15_bt::checksum(data: &[u8]) -> u8`.

## Command pacing (required)

The write characteristic advertises WRITE_WITHOUT_RESPONSE, and `Device::send`
uses it. That has **no flow control**: commands written back-to-back are
silently dropped, with no error reported to the host.

Leave at least **120 ms** between consecutive commands
(`el15_bt::INTER_COMMAND_GAP`). Use `Device::send_sequence()` for any burst
rather than consecutive `send()` calls.

Measured on HW:2.0 / SW:1.7 — three commands (set mode, set setpoint, set CAP
current) sent with no gap:

```
>> af 07 03 03 01 02 41 set mode CAP
>> af 07 03 04 04 … set setpoint (no gap)
>> af 07 03 09 01 04 39 load on (no gap)
<< df 07 03 03 01 00 13 ACK for the mode command only
<< df 01 04 39 01 02 e0 malformed — contains 04 39 from the outgoing frame
```

The CAP current read back **unchanged 3 times out of 3**; spaced 120 ms apart
the identical sequence took effect 3 times out of 3. Symptomatically this looks
like a UI that needs several button presses before a command "takes".

The poll timer must also be held off for the duration of a burst, otherwise the
periodic poll becomes one more back-to-back write (`AppState::pause_ticks_for`).

## Connection Handshake

After BLE connection and characteristic subscription, the host **must** send
Expand Down Expand Up @@ -194,14 +222,111 @@ The `el15-bt` library emits these events from the notification stream:
| ---------------------------- | ----------------------------------------------------- |
| `Status(EL15Status)` | Any 28-byte notification with header `DF 07 03 08` |
| `FirmwareVersion(String)` | Init response with header `DF FF FF`; parsed by `parse_firmware_version` |
| `CapCurrent(f32)` | CAP discharge-current reply `DF 07 03 0A 04 <f32>`, in Amps |
| `RawNotification(Vec<u8>)` | Every notification, unfiltered (for debugging) |
| `Disconnected` | GATT notification stream ends |

### CAP discharge current (cmd `0x05` write / `0x0A` read)

The capacity-test discharge current has its **own opcode** — the ordinary
`set setpoint` (`0x04`) does not reach it.

| Direction | Bytes |
| --------- | ---------------------------------------- |
| Write | `AF 07 03 05 04 <f32 LE amps> <csum>` |
| Read | `AF 07 03 0A 00 3D` |
| Response | `DF 07 03 0A 04 <f32 LE amps> <csum>` |

Verified end-to-end on HW:2.0 / SW:1.7:

```
>> af 07 03 0a 00 3d read
<< df 07 03 0a 04 00 00 a0 40 29 5.0 A (device showed 5000 mA)
>> af 07 03 05 04 00 00 00 40 fe write 2.0 A
<< df 07 03 05 01 00 11 ACK, status 00
>> af 07 03 0a 00 3d read
<< df 07 03 0a 04 04 00 00 40 c5 2.0 A (device showed 2000 mA)
```

Range is `0 – 12000 mA` (manual §3.4.1). The value is in **Amps**, not
milliamps. The device stores milliamps internally, so a value written as `5.0`
reads back as `5.0000010` — never compare the readback for exact equality.

The current is **not** carried in the status packet: in CAP mode bytes 23..27
hold the measured capacity. `0x0A` is the only way to observe it. The register
is global — it reads the same in CC, CAP and DCR mode.

### Command map

Every opcode in `AF 07 03 <cmd> <len> …` was probed on real hardware. `len = 0`
means *read*, `len > 0` means *write*.

| cmd | Read (`len=0`) | Write | Meaning |
| ------ | ----------------------- | ---------------- | -------------------------------- |
| `0x00` | code `03` | code `03` | invalid |
| `0x01` | code `00` | — | accepted, no observed effect |
| `0x02` | 3 bytes `00 11 14` | — | **unidentified** (constant) |
| `0x03` | — | `len=1` mode | set mode |
| `0x04` | — | `len=4` f32 | set setpoint (CC/CV/CR/CP only) |
| `0x05` | code `04` | `len=4` f32 A | **set CAP discharge current** |
| `0x06` | code `04` | **only `len=10`** | **unidentified** (see notes) |
| `0x07` | device name | — | info |
| `0x08` | 28-byte status | — | poll |
| `0x09` | — | `len=1` | load on / off / lock |
| `0x0A` | 4 bytes f32 A | code `04` | **read CAP discharge current** |
| `0x0B`+| code `05` | — | unknown |

Acknowledgement status byte (`DF 07 03 <cmd> 01 <status>`):

| Status | Meaning |
| ------ | ---------------------------------------- |
| `0x00` | accepted |
| `0x03` | invalid command |
| `0x04` | command exists, wrong payload length |
| `0x05` | unknown command |

## Operational notes

- **CAP mode** uses the discharge current stored in device memory (set via
front panel or previous session). The `set setpoint` command does not affect
CAP discharge current.
- **DCR test currents and timer are not available over BLE.** Verified on
HW:2.0 / SW:1.7: with the device at its factory 20 mA / 1000 mA, neither
`0x04` (setpoint) nor `0x05` (CAP current) moves them — both are acknowledged
with status `00` and the status packet keeps reporting 20 mA / 1000 mA. Like
the CAP cutoff these are front-panel settings (manual §3.4.2 "DCR Params").
They **are** readable: in DCR mode status bytes 15..19 and 19..23 carry I1 and
I2 in Amps, so the app displays them read-only.
- **`0x06` takes a 10-byte payload — purpose unknown.** A length sweep found
that `0x06` rejects every payload length (code `04`) *except* 10, which is
acknowledged with status `00`. Three plausible 10-byte layouts were tried
(`f32 A + f32 A + u16`, `f32 mA + f32 mA + u16`, `u16 mA + u16 mA + u16 + pad`)
and none changed the reported DCR currents. It remains the best candidate for
a DCR- or CAP-parameter block, but it is **not** identified — do not guess at
it in code.
- **CAP cutoff voltage and timer are not available over BLE.** Every opcode was
probed; none reads or writes them. They are front-panel settings (manual
§3.4.2 "CAP Params") and the protocol has no equivalent. The app must not
present them as device controls — see `docs/GUI_DESIGN.md`.
- **`set setpoint` is silently ignored in CAP and DCR.** Sending
`AF 07 03 04 04 <f32>` while in CAP mode is acknowledged with
`DF 07 03 04 01 00 12` — status `00`, identical to the CC-mode
acknowledgement — but changes neither the discharge current nor the cutoff.
Use `0x05` for the CAP discharge current.
- **An ACK is not proof of effect.** The status byte reports that the frame was
well-formed, not that it altered anything. Confirm against a readback
(`0x0A`) or the device display before concluding a command works.
- **Reference capture caveat.** `logs/series_1/btsnoop_hci.log` contains only
six command types, because its CAP step was recorded with factory defaults
(`logs/series_1/protocol.md`, step 9: "Send CAP. Default values."). Absence
from that capture does not mean a command does not exist — `0x05` and `0x0A`
are both absent from it and both work.
- **Grepping the capture:** filter on the write handle, not the payload prefix.
Commands go to handle `0x0009`, notifications arrive on `0x0006`. A 28-byte
status notification fragments as 20+8 on Android, and the 8-byte tail can
begin with `AF` by coincidence, which looks exactly like a command.
- **Characteristics:** the official Android app writes to `FFF3` and subscribes
to `FFF2`. This client uses `FFF1` for both, which the device also accepts —
`Device::connect` picks the first writable/notifying characteristic on the
`FFF0` service rather than a fixed UUID. No command has been found that works
on one characteristic but not the other.
- **DCR mode** auto-stops after measurement completes. The `dcr_mohm` field
reports resistance in **Ohms** (not milliohms despite the field name).
- **Android BLE MTU:** On Android, 28-byte status notifications may arrive
Expand Down
49 changes: 40 additions & 9 deletions docs/GUI_DESIGN.md
Original file line number Diff line number Diff line change
Expand Up @@ -74,16 +74,47 @@ Right side — three stacked info cells (mode-dependent):
Located in the right column, below the info cards. Hidden for CC/CV/CR/CP modes.

**CAP mode (Capacity Test):**
- Line 1: Timer enable/disable toggle. Duration input (HH:MM:SS) is visible only when timer is enabled, on the same line as the Timer toggle.
- Line 2: Cutoff voltage input (always visible, range 0.1–60.0 V) + Chemistry type selector (N/A, NiMH/NiCd, NiZn, Li-Ion, LiPo, LiFePO4, Na-Ion) + Cells count combo box (visible only when chemistry is not N/A; allows picking 1–20 from dropdown or typing any value directly).
- When chemistry is selected, cutoff voltage is auto-calculated as (per-cell voltage × number of cells).
- Per-cell cutoff voltages: NiMH/NiCd=1.00V, NiZn=1.20V, Li-Ion=3.00V, LiPo=3.00V, LiFePO4=2.50V, Na-Ion=2.00V.
- Chemistry/cells selections are persisted in settings.

Only one CAP parameter is reachable over Bluetooth. The BLE protocol has a dedicated opcode for
the discharge current (write `0x05`, read `0x0A`) but **no command at all** for the cutoff voltage
or the timer — those are front-panel settings. See `docs/BT_PROTOCOL.md`.

- Line 1: **Discharge current** input (A, range 0–12, i.e. 0–12000 mA) + "Set" button. This is
sent to the device with opcode `0x05`. "Set" is disabled while no device is connected or the
value is out of range.
- Line 2: a static note that Cutoff / Timer are set on the device and are not available over
Bluetooth.
- Line 3 (temporary removed): **"On device: N.NNN A (NNNN mA)"** — the value read back with `0x0A` after each write.
On its own line, not beside the input, so the editable value and the device's value are not
confused. The device stores milliamps, so the readback is quantised (5.0 comes back as
5.0000010) and must never be compared for exact equality. Shows "—" until the device answers.
- The discharge current is **not** present in the status packet, so it is requested explicitly on
entering CAP mode and after every write.

*Previously* this panel offered editable Timer, Cutoff voltage, Chemistry and Cells controls. None
of them was ever transmitted to the device, and no BLE command exists that could transmit them —
they silently did nothing. Those editors are commented out in `battery_params_panel` (kept, not
deleted, so they can be restored if a firmware revision exposes the parameters), and their settings
fields are retained as local notes.

**DCR mode (DC Internal Resistance Test):**
- I1 current (mA, range 20–12000)
- I2 current (mA, range 20–12000)
- Timer (seconds, range 1–99)

Like the CAP cutoff, the DCR test currents and timer have **no BLE command** — they are front-panel
settings (manual §3.4.2 "DCR Params"). Unlike the CAP cutoff, they are *readable*: the DCR status
packet carries both test currents.

- Line 1: a static note that the test currents and timer are set on the device and are not
available over Bluetooth.
- Line 2: read-only **"On device: I1: 20 mA I2: 1000 mA"**, taken from status bytes 15..19 and
19..23 (Amps on the wire, shown in mA). Shows "—" until the first status packet arrives.

Both mode panels use the same order — actionable controls, then the device-only notice, then the
read-back values — and share one i18n key for the notice text (`label.device_only_hint`), which is
therefore named after neither mode.

*Previously* this panel offered editable I1 / I2 / Timer inputs. None was ever transmitted, and no
command exists that could transmit them. Those editors are commented out in `battery_params_panel`
(kept, not deleted) and their settings fields are retained as local notes.

### 5. Chart
- V/I/P graph with per-trace toggles (V, I, P colored buttons)
Expand Down Expand Up @@ -157,7 +188,7 @@ Located in the right column, below the info cards. Hidden for CC/CV/CR/CP modes.
| CV | Set Voltage | V | 0.100–60.000 |
| CR | Set Resistance | Ω | 0.1–7500.0 |
| CP | Set Power | W | 0.00–150.00 |
| CAP | Cutoff V | V | 0.1–60.0 |
| CAP | Discharge current | A | 0.000–12.000 (device range 0–12000 mA) |
| DCR | Current | mA | 20–12000 |

### Setpoint Validation
Expand Down
5 changes: 5 additions & 0 deletions el15-app/locales/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,11 @@
"label.timer": "Timer",
"label.duration": "Duration",
"label.cutoff_v": "Cutoff V",
"label.discharge_current": "Discharge current",
"label.on_device": "On device",
"label.cap_device_only": "Cutoff / Timer",
"label.dcr_device_only": "Test currents / Timer",
"label.device_only_hint": "set on the device — not available over Bluetooth",
"label.set_current": "Set Current",
"label.set_voltage": "Set Voltage",
"label.set_resistance": "Set Resistance",
Expand Down
5 changes: 5 additions & 0 deletions el15-app/locales/es.json
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,11 @@
"label.timer": "Temporizador",
"label.duration": "Duración",
"label.cutoff_v": "V corte",
"label.discharge_current": "Corriente de descarga",
"label.on_device": "En el dispositivo",
"label.cap_device_only": "Corte / Temporizador",
"label.dcr_device_only": "Corrientes de prueba / Temporizador",
"label.device_only_hint": "se ajustan en el dispositivo: no disponibles por Bluetooth",
"label.set_current": "Corriente fijada",
"label.set_voltage": "Tensión fijada",
"label.set_resistance": "Resistencia fijada",
Expand Down
5 changes: 5 additions & 0 deletions el15-app/locales/hi.json
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,11 @@
"label.timer": "टाइमर",
"label.duration": "अवधि",
"label.cutoff_v": "कटऑफ V",
"label.discharge_current": "डिस्चार्ज करंट",
"label.on_device": "डिवाइस पर",
"label.cap_device_only": "कट-ऑफ / टाइमर",
"label.dcr_device_only": "परीक्षण धाराएँ / टाइमर",
"label.device_only_hint": "डिवाइस पर सेट करें — ब्लूटूथ से उपलब्ध नहीं",
"label.set_current": "करंट सेट करें",
"label.set_voltage": "वोल्टेज सेट करें",
"label.set_resistance": "प्रतिरोध सेट करें",
Expand Down
5 changes: 5 additions & 0 deletions el15-app/locales/ru.json
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,11 @@
"label.timer": "Таймер",
"label.duration": "Длительность",
"label.cutoff_v": "Отсечка В",
"label.discharge_current": "Ток разряда",
"label.on_device": "На устройстве",
"label.cap_device_only": "Отсечка / Таймер",
"label.dcr_device_only": "Токи измерения / Таймер",
"label.device_only_hint": "задаются на устройстве — недоступны по Bluetooth",
"label.set_current": "Задать ток",
"label.set_voltage": "Задать напряжение",
"label.set_resistance": "Задать сопротивление",
Expand Down
5 changes: 5 additions & 0 deletions el15-app/locales/zh.json
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,11 @@
"label.timer": "计时器",
"label.duration": "持续时间",
"label.cutoff_v": "截止电压",
"label.discharge_current": "放电电流",
"label.on_device": "设备上",
"label.cap_device_only": "截止电压 / 定时器",
"label.dcr_device_only": "测试电流 / 定时器",
"label.device_only_hint": "在设备上设置 — 蓝牙不支持",
"label.set_current": "设定电流",
"label.set_voltage": "设定电压",
"label.set_resistance": "设定电阻",
Expand Down
4 changes: 4 additions & 0 deletions el15-app/src/cli_run.rs
Original file line number Diff line number Diff line change
Expand Up @@ -123,6 +123,7 @@ async fn try_connect(args: &Cli, state: &SharedState) -> Result<Arc<Device>> {
match ev {
DeviceEvent::Status(s) => st_clone.update_status(s).await,
DeviceEvent::FirmwareVersion(ver) => info!("firmware version: {ver}"),
DeviceEvent::CapCurrent(amps) => info!("CAP discharge current: {amps} A"),
DeviceEvent::RawNotification(_) => {}
DeviceEvent::Disconnected => {
warn!("device disconnected");
Expand Down Expand Up @@ -220,6 +221,9 @@ async fn run_debug_shell(args: &Cli) -> Result<()> {
DeviceEvent::FirmwareVersion(ver) => {
println!(" << FIRMWARE VERSION: {ver}");
}
DeviceEvent::CapCurrent(amps) => {
println!(" << CAP DISCHARGE CURRENT: {amps:.4} A ({:.0} mA)", amps * 1000.0);
}
DeviceEvent::Disconnected => {
println!(" << DISCONNECTED");
break;
Expand Down
Loading