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 {