From 8074cd4ad8b5e2c81943c58ae8b40bc34db6e1d4 Mon Sep 17 00:00:00 2001 From: Michael Huber Date: Mon, 3 Aug 2026 14:02:12 +0000 Subject: [PATCH] [PATCH] Add usage counter reset with AUTHLIM warning Adds the ability to reset the brush head's usage counter to zero. Flow: Read head -> press Right ("Reset") -> confirm warning dialog -> hold head to Flipper again. The app authenticates with the derived password via the poller's AuthRequest event and writes the official reset payload (00 00 02 00) to page 0x24. The confirm dialog warns about the NTAG213 AUTHLIM feature which permanently locks the tag after 3 wrong password attempts. --- CHANGELOG.md | 3 + README.md | 9 +- application.fam | 4 +- scenes/sonicare_scene_config.h | 3 + scenes/sonicare_scene_read_complete.c | 24 ++++- scenes/sonicare_scene_reset.c | 122 +++++++++++++++++++++++++ scenes/sonicare_scene_reset_complete.c | 80 ++++++++++++++++ scenes/sonicare_scene_reset_confirm.c | 68 ++++++++++++++ uk_mbirth_sonicare.h | 12 +++ 9 files changed, 317 insertions(+), 8 deletions(-) create mode 100644 scenes/sonicare_scene_reset.c create mode 100644 scenes/sonicare_scene_reset_complete.c create mode 100644 scenes/sonicare_scene_reset_confirm.c diff --git a/CHANGELOG.md b/CHANGELOG.md index 8202b15..367f7c7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,2 +1,5 @@ +v1.0: +- Add: Reset usage counter (with AUTHLIM warning dialog) + v0.9: Initial Release diff --git a/README.md b/README.md index 76558d1..b5f0bb7 100644 --- a/README.md +++ b/README.md @@ -21,9 +21,16 @@ Features This Flipper Zero app will scan the NFC chip and show the UID, MFG code, lifespan, as well as the used time. It'll also generate the NFC password. +It can also **reset the usage counter** of the brush head. After reading a head, press the +right button ("Reset"), confirm the warning dialog, and hold the head to the Flipper again. + +> **Warning:** The NTAG213 in the brush heads permanently disables all write access after +> **3 wrong password attempts** (AUTHLIM). The password is derived correctly from UID + MFG, +> but if authentication fails for any reason, do NOT retry more than twice. + TODO ---- * determine brush head type from [data](https://blog.mbirth.uk/2026/03/29/sonicare-brush-head-nfc-data.html) -* allow writing back modified data (different type, reset usage) +* allow writing other data (different type) diff --git a/application.fam b/application.fam index 847271b..c4606f6 100644 --- a/application.fam +++ b/application.fam @@ -9,9 +9,9 @@ App( fap_category="NFC", # Optional values requires=["gui"], - fap_version="0.9", + fap_version="1.0", fap_icon="uk_mbirth_sonicare.png", # 10x10 1-bit PNG - fap_description="Philips Sonicare brush head NFC reader", + fap_description="Philips Sonicare brush head NFC reader + usage reset", fap_author="mbirth.uk", fap_weburl="https://git.mbirth.uk/flipper_zero/sonicare", fap_icon_assets="images", # Image assets to compile for this application diff --git a/scenes/sonicare_scene_config.h b/scenes/sonicare_scene_config.h index d1ce9b2..9c7ba6e 100644 --- a/scenes/sonicare_scene_config.h +++ b/scenes/sonicare_scene_config.h @@ -2,3 +2,6 @@ ADD_SCENE(sonicare, start, Start) ADD_SCENE(sonicare, about, About) ADD_SCENE(sonicare, read, Read) ADD_SCENE(sonicare, read_complete, ReadComplete) +ADD_SCENE(sonicare, reset_confirm, ResetConfirm) +ADD_SCENE(sonicare, reset, Reset) +ADD_SCENE(sonicare, reset_complete, ResetComplete) diff --git a/scenes/sonicare_scene_read_complete.c b/scenes/sonicare_scene_read_complete.c index 40230db..330f182 100644 --- a/scenes/sonicare_scene_read_complete.c +++ b/scenes/sonicare_scene_read_complete.c @@ -44,9 +44,18 @@ void sonicare_scene_read_complete_on_enter(void* context) { furi_string_cat_printf(temp_str, "\e#%s\n", nfc_device_get_name(nfc_device, NfcDeviceNameTypeFull)); // UID - furi_string_cat_printf(temp_str, "UID:"); + furi_string_cat_str(temp_str, "UID:"); format_bytes(temp_str, ul_data->iso14443_3a_data->uid, ul_data->iso14443_3a_data->uid_len); - furi_string_cat_printf(temp_str, "\n"); + furi_string_cat_str(temp_str, "\n"); + + // Cache UID + MFG for the reset scene's password derivation. + // MFG (10 bytes, e.g. "221214 12K") = page[0x21].data[2..3] + + // page[0x22].data[0..3] + page[0x23].data[0..3] + // (first 2 bytes of page 0x21 are the max-usage value, not MFG). + memcpy(app->sonicare_uid, ul_data->iso14443_3a_data->uid, 7); + memcpy(app->sonicare_mfg + 0, ul_data->page[0x21].data + 2, 2); + memcpy(app->sonicare_mfg + 2, ul_data->page[0x22].data, 4); + memcpy(app->sonicare_mfg + 6, ul_data->page[0x23].data, 4); // Manufacturing Code furi_string_cat_str(temp_str, "MFG: "); @@ -98,18 +107,23 @@ void sonicare_scene_read_complete_on_enter(void* context) { furi_string_free(temp_str); furi_string_free(serial_no); - // TODO: widget_add_button_element(widget, GuiButtonTypeRight, "Change", sonicare_scene_read_complete_widget_callback, app); + widget_add_button_element( + widget, + GuiButtonTypeRight, + "Reset", + sonicare_scene_read_complete_widget_callback, + app); view_dispatcher_switch_to_view(app->view_dispatcher, SonicareViewWidget); } bool sonicare_scene_read_complete_on_event(void* context, SceneManagerEvent event) { - UNUSED(context); + Sonicare* app = context; bool consumed = false; if (event.type == SceneManagerEventTypeCustom) { if (event.event == GuiButtonTypeRight) { - // switch to edit screen + scene_manager_next_scene(app->scene_manager, SonicareSceneResetConfirm); consumed = true; } } else if (event.type == SceneManagerEventTypeBack) { diff --git a/scenes/sonicare_scene_reset.c b/scenes/sonicare_scene_reset.c new file mode 100644 index 0000000..0564174 --- /dev/null +++ b/scenes/sonicare_scene_reset.c @@ -0,0 +1,122 @@ +#include "../uk_mbirth_sonicare.h" +#include "../sonicare_password.h" +#include + +#include +#include +#include +#include +#include +#include +#include +#include + +#define SONICARE_PAGE_USAGE 0x24 + +static NfcCommand sonicare_scene_reset_poller_callback(NfcGenericEvent event, void* context) { + furi_assert(context); + Sonicare* app = context; + + if(event.protocol != NfcProtocolMfUltralight) { + return NfcCommandContinue; + } + + const MfUltralightPollerEvent* ev = event.event_data; + NfcCommand command = NfcCommandContinue; + + if(ev->type == MfUltralightPollerEventTypeRequestMode) { + ev->data->poller_mode = MfUltralightPollerModeRead; + } else if(ev->type == MfUltralightPollerEventTypeAuthRequest) { + // The poller asks us for the password before reading protected pages. + // Provide it (MSB-first, as in the 8-digit hex string). + uint32_t pwd = get_sonicare_password(app->sonicare_uid, app->sonicare_mfg); + FURI_LOG_I("sonicare_scene_reset", "AuthRequest - providing password %08lX", pwd); + ev->data->auth_context.password.data[0] = (pwd >> 24) & 0xFF; + ev->data->auth_context.password.data[1] = (pwd >> 16) & 0xFF; + ev->data->auth_context.password.data[2] = (pwd >> 8) & 0xFF; + ev->data->auth_context.password.data[3] = (pwd >> 0) & 0xFF; + ev->data->auth_context.skip_auth = false; + } else if(ev->type == MfUltralightPollerEventTypeAuthSuccess) { + FURI_LOG_I("sonicare_scene_reset", "Auth success, writing page 0x24"); + + MfUltralightPoller* poller = (MfUltralightPoller*)event.instance; + + // Official reset: write page 0x24 = {00 00 02 00} + MfUltralightPage page = {.data = {0x00, 0x00, 0x02, 0x00}}; + MfUltralightError err = + mf_ultralight_poller_write_page(poller, SONICARE_PAGE_USAGE, &page); + + if(err == MfUltralightErrorNone) { + FURI_LOG_I("sonicare_scene_reset", "Usage counter reset OK"); + app->reset_state = SonicareResetStateSuccess; + } else { + FURI_LOG_E("sonicare_scene_reset", "Write failed: %d", err); + app->reset_state = SonicareResetStateFailedWrite; + } + + view_dispatcher_send_custom_event(app->view_dispatcher, NfcCustomEventWorkerExit); + command = NfcCommandStop; + } else if(ev->type == MfUltralightPollerEventTypeAuthFailed) { + FURI_LOG_E("sonicare_scene_reset", "Auth failed (poller state machine)"); + app->reset_state = SonicareResetStateFailedAuth; + view_dispatcher_send_custom_event(app->view_dispatcher, NfcCustomEventWorkerExit); + command = NfcCommandStop; + } else if(ev->type == MfUltralightPollerEventTypeReadSuccess) { + // Card has no password protection or auth was skipped; try direct write. + FURI_LOG_I("sonicare_scene_reset", "ReadSuccess without prior auth, trying write"); + MfUltralightPoller* poller = (MfUltralightPoller*)event.instance; + MfUltralightPage page = {.data = {0x00, 0x00, 0x02, 0x00}}; + MfUltralightError err = + mf_ultralight_poller_write_page(poller, SONICARE_PAGE_USAGE, &page); + app->reset_state = + (err == MfUltralightErrorNone) ? SonicareResetStateSuccess : SonicareResetStateFailedWrite; + view_dispatcher_send_custom_event(app->view_dispatcher, NfcCustomEventWorkerExit); + command = NfcCommandStop; + } + + return command; +} + +void sonicare_scene_reset_on_enter(void* context) { + Sonicare* app = context; + Popup* popup = app->popup; + + app->reset_state = SonicareResetStateInit; + + popup_reset(popup); + popup_set_header(popup, "Resetting", 83, 8, AlignCenter, AlignTop); + popup_set_text(popup, "Hold brush stem\nnext to\nFlipper's back", 83, 27, AlignCenter, AlignTop); + popup_set_icon(app->popup, 0, 0, &I_sonicare_read); + view_dispatcher_switch_to_view(app->view_dispatcher, SonicareViewPopup); + + app->poller = nfc_poller_alloc(app->nfc, NfcProtocolMfUltralight); + nfc_poller_start(app->poller, sonicare_scene_reset_poller_callback, app); +} + +bool sonicare_scene_reset_on_event(void* context, SceneManagerEvent event) { + Sonicare* app = context; + bool consumed = false; + + if(event.type == SceneManagerEventTypeCustom) { + if(event.event == NfcCustomEventWorkerExit) { + if(app->reset_state == SonicareResetStateSuccess) { + notification_message(app->notifications, &sequence_success); + dolphin_deed(DolphinDeedNfcRead); + } else { + notification_message(app->notifications, &sequence_error); + } + scene_manager_next_scene(app->scene_manager, SonicareSceneResetComplete); + consumed = true; + } + } + + return consumed; +} + +void sonicare_scene_reset_on_exit(void* context) { + Sonicare* app = context; + + nfc_poller_stop(app->poller); + nfc_poller_free(app->poller); + popup_reset(app->popup); +} diff --git a/scenes/sonicare_scene_reset_complete.c b/scenes/sonicare_scene_reset_complete.c new file mode 100644 index 0000000..0ec184a --- /dev/null +++ b/scenes/sonicare_scene_reset_complete.c @@ -0,0 +1,80 @@ +#include "../uk_mbirth_sonicare.h" +#include +#include +#include +#include +#include +#include + +static void sonicare_scene_reset_complete_widget_callback( + GuiButtonType result, + InputType type, + void* context) { + furi_assert(context); + Sonicare* app = context; + if(type == InputTypeShort) { + view_dispatcher_send_custom_event(app->view_dispatcher, result); + } +} + +void sonicare_scene_reset_complete_on_enter(void* context) { + Sonicare* app = context; + Widget* widget = app->widget; + + widget_reset(widget); + + if(app->reset_state == SonicareResetStateSuccess) { + widget_add_icon_element(widget, 0, 0, &I_sonicare_brush); + widget_add_string_element( + widget, 64, 20, AlignCenter, AlignCenter, FontPrimary, "Counter reset!"); + widget_add_string_element( + widget, + 64, + 40, + AlignCenter, + AlignCenter, + FontSecondary, + "Read again to verify"); + } else { + const char* reason = + (app->reset_state == SonicareResetStateFailedAuth) ? "Auth failed" : "Write failed"; + widget_add_string_element( + widget, 64, 20, AlignCenter, AlignCenter, FontPrimary, "Reset failed"); + widget_add_string_element( + widget, 64, 40, AlignCenter, AlignCenter, FontSecondary, reason); + } + + widget_add_button_element( + widget, + GuiButtonTypeLeft, + "Back", + sonicare_scene_reset_complete_widget_callback, + app); + + view_dispatcher_switch_to_view(app->view_dispatcher, SonicareViewWidget); +} + +bool sonicare_scene_reset_complete_on_event(void* context, SceneManagerEvent event) { + Sonicare* app = context; + bool consumed = false; + + if(event.type == SceneManagerEventTypeCustom) { + if(event.event == GuiButtonTypeLeft) { + // Go back to start menu so user can re-read the head + scene_manager_search_and_switch_to_previous_scene( + app->scene_manager, SonicareSceneStart); + consumed = true; + } + } else if(event.type == SceneManagerEventTypeBack) { + scene_manager_search_and_switch_to_previous_scene( + app->scene_manager, SonicareSceneStart); + consumed = true; + } + + return consumed; +} + +void sonicare_scene_reset_complete_on_exit(void* context) { + Sonicare* app = context; + widget_reset(app->widget); +} diff --git a/scenes/sonicare_scene_reset_confirm.c b/scenes/sonicare_scene_reset_confirm.c new file mode 100644 index 0000000..abdf3fb --- /dev/null +++ b/scenes/sonicare_scene_reset_confirm.c @@ -0,0 +1,68 @@ +#include "../uk_mbirth_sonicare.h" +#include +#include +#include +#include +#include +#include + +static void sonicare_scene_reset_confirm_widget_callback( + GuiButtonType result, + InputType type, + void* context) { + furi_assert(context); + Sonicare* app = context; + if(type == InputTypeShort) { + view_dispatcher_send_custom_event(app->view_dispatcher, result); + } +} + +void sonicare_scene_reset_confirm_on_enter(void* context) { + Sonicare* app = context; + Widget* widget = app->widget; + + widget_reset(widget); + + widget_add_string_element( + widget, 64, 2, AlignCenter, AlignTop, FontPrimary, "Reset usage counter?"); + widget_add_text_box_element( + widget, + 0, + 14, + 128, + 38, + AlignLeft, + AlignTop, + "\e#Warning:\e# The NTAG213 permanently\nlocks after 3 wrong\npassword attempts.", + false); + + widget_add_button_element( + widget, GuiButtonTypeLeft, "Cancel", sonicare_scene_reset_confirm_widget_callback, app); + widget_add_button_element( + widget, GuiButtonTypeRight, "Reset", sonicare_scene_reset_confirm_widget_callback, app); + + view_dispatcher_switch_to_view(app->view_dispatcher, SonicareViewWidget); +} + +bool sonicare_scene_reset_confirm_on_event(void* context, SceneManagerEvent event) { + Sonicare* app = context; + bool consumed = false; + + if(event.type == SceneManagerEventTypeCustom) { + if(event.event == GuiButtonTypeRight) { + scene_manager_next_scene(app->scene_manager, SonicareSceneReset); + consumed = true; + } else if(event.event == GuiButtonTypeLeft) { + consumed = scene_manager_previous_scene(app->scene_manager); + } + } else if(event.type == SceneManagerEventTypeBack) { + consumed = scene_manager_previous_scene(app->scene_manager); + } + + return consumed; +} + +void sonicare_scene_reset_confirm_on_exit(void* context) { + Sonicare* app = context; + widget_reset(app->widget); +} diff --git a/uk_mbirth_sonicare.h b/uk_mbirth_sonicare.h index 67616b0..b7b9636 100644 --- a/uk_mbirth_sonicare.h +++ b/uk_mbirth_sonicare.h @@ -32,6 +32,13 @@ typedef struct Sonicare Sonicare; +typedef enum { + SonicareResetStateInit, + SonicareResetStateSuccess, + SonicareResetStateFailedAuth, + SonicareResetStateFailedWrite, +} SonicareResetState; + struct Sonicare { ViewDispatcher* view_dispatcher; Gui* gui; @@ -54,6 +61,11 @@ struct Sonicare { NfcListener* listener; NfcDevice* nfc_device; MfUltralightData* nfc_data; + + // Reset flow + SonicareResetState reset_state; + uint8_t sonicare_uid[7]; + uint8_t sonicare_mfg[10]; }; typedef enum {