├── appicon.png ├── assets ├── icon.png ├── original-servo-tester.jpeg └── icon.pixil ├── binaries └── servotester.fap ├── .gitignore ├── application.fam ├── README.md ├── LICENSE └── app.c /appicon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mhasbini/ServoTesterApp/HEAD/appicon.png -------------------------------------------------------------------------------- /assets/icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mhasbini/ServoTesterApp/HEAD/assets/icon.png -------------------------------------------------------------------------------- /binaries/servotester.fap: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mhasbini/ServoTesterApp/HEAD/binaries/servotester.fap -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .vscode/ 2 | assets/original/ 3 | assets/* 4 | !assets/icon.* 5 | !assets/original-servo-tester.jpeg -------------------------------------------------------------------------------- /assets/original-servo-tester.jpeg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mhasbini/ServoTesterApp/HEAD/assets/original-servo-tester.jpeg -------------------------------------------------------------------------------- /application.fam: -------------------------------------------------------------------------------- 1 | App( 2 | appid="servotester", 3 | name="Servo Tester", 4 | apptype=FlipperAppType.EXTERNAL, 5 | entry_point="servotester_app_entry", 6 | cdefines=["APP_PROTOVIEW"], 7 | requires=["gui"], 8 | stack_size=8*1024, 9 | order=50, 10 | fap_icon="appicon.png", 11 | fap_category="GPIO", 12 | ) -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Servo tester app for Flipper Zero 2 | 3 | 4 | https://user-images.githubusercontent.com/8887221/228034391-aa9df44c-dcf7-4999-9d22-590cc7ac0392.mp4 5 | 6 | The aim of this app is to replicate the behavior from this tester: 7 | 8 | ![](assets/original-servo-tester.jpeg) 9 | 10 | You can read more about it in this blog ! link to blog here 11 | 12 | ## Usage 13 | 14 | - Connect Servo control line to `A7` pin. 15 | - Start app 16 | - If you're supplying power from a power source, make sure it have common ground with the flipper. 17 | 18 | ## Controls 19 | 20 | - Left/Right: change angle in manual mode. 21 | - Up/Down: change mode 22 | 23 | The app have three modes: 24 | 25 | - Manual: left increase 10°, right dcreace 10°. 26 | - Center: move to 90°. 27 | - Sweep: move between 0° & 180° every 1 second. 28 | 29 | Angle range is: 0° to 180°. 30 | 31 | ## Installing 32 | 33 | Drop the `servotester.fap` from `./binaries` to `/ext/apps/GPIO/`. 34 | 35 | ## Building 36 | 37 | - Navigate to your firmware directory > `./application_user` 38 | - copy `servotester/` content. 39 | - run `./fbt launch_app APPSRC=servotester` 40 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2023 mhasbini 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining a copy 4 | of this software and associated documentation files (the "Software"), to deal 5 | in the Software without restriction, including without limitation the rights 6 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 7 | copies of the Software, and to permit persons to whom the Software is 8 | furnished to do so, subject to the following conditions: 9 | 10 | The above copyright notice and this permission notice shall be included in all 11 | copies or substantial portions of the Software. 12 | 13 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 14 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 15 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 16 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 17 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 18 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 19 | SOFTWARE. 20 | -------------------------------------------------------------------------------- /assets/icon.pixil: -------------------------------------------------------------------------------- 1 | {"application":"pixil","type":".pixil","version":"2.7.0","website":"pixilart.com","author":"https://www.pixilart.com","contact":"support@pixilart.com","width":10,"height":10,"colors":{"default":["000000","ffffff","f44336","E91E63","9C27B0","673AB7","3F51B5","2196F3","03A9F4","00BCD4","009688","4CAF50","8BC34A","CDDC39","FFEB3B","FFC107","FF9800","FF5722","795548","9E9E9E","607D8B","ffebee","ffcdd2","ef9a9a","e57373","ef5350","e53935","d32f2f","c62828","b71c1c","ff8a80","ff5252","ff1744","d50000","fce4ec","f8bbd0","f48fb1","f06292","ec407a","e91e63","d81b60","c2185b","ad1457","880e4f","ff80ab","ff4081","f50057","c51162","f3e5f5","e1bee7","ce93d8","ba68c8","ab47bc","9c27b0","8e24aa","7b1fa2","6a1b9a","4a148c","ea80fc","e040fb","d500f9","aa00ff","ede7f6","d1c4e9","b39ddb","9575cd","7e57c2","673ab7","5e35b1","512da8","4527a0","311b92","b388ff","7c4dff","651fff","6200ea","e8eaf6","c5cae9","9fa8da","7986cb","5c6bc0","3f51b5","3949ab","303f9f","283593","1a237e","8c9eff","536dfe","3d5afe","304ffe","e3f2fd","bbdefb","90caf9","64b5f6","42a5f5","2196f3","1e88e5","1976d2","1565c0","0d47a1","82b1ff","448aff","2979ff","2962ff","e1f5fe","b3e5fc","81d4fa","4fc3f7","29b6f6","03a9f4","039be5","0288d1","0277bd","01579b","80d8ff","40c4ff","00b0ff","0091ea","e0f7fa","b2ebf2","80deea","4dd0e1","26c6da","00bcd4","00acc1","0097a7","00838f","006064","84ffff","18ffff","00e5ff","00b8d4","e0f2f1","b2dfdb","80cbc4","4db6ac","26a69a","009688","00897b","00796b","00695c","004d40","a7ffeb","64ffda","1de9b6","00bfa5","e8f5e9","c8e6c9","a5d6a7","81c784","66bb6a","4caf50","43a047","388e3c","2e7d32","1b5e20","b9f6ca","69f0ae","00e676","00c853","f1f8e9","dcedc8","c5e1a5","aed581","9ccc65","8bc34a","7cb342","689f38","558b2f","33691e","ccff90","b2ff59","76ff03","64dd17","f9fbe7","f0f4c3","e6ee9c","dce775","d4e157","cddc39","c0ca33","afb42b","9e9d24","827717","f4ff81","eeff41","c6ff00","aeea00","fffde7","fff9c4","fff59d","fff176","ffee58","ffeb3b","fdd835","fbc02d","f9a825","f57f17","ffff8d","ffff00","ffea00","ffd600","fff8e1","ffecb3","ffe082","ffd54f","ffca28","ffc107","ffb300","ffa000","ff8f00","ff6f00","ffe57f","ffd740","ffc400","ffab00","fff3e0","ffe0b2","ffcc80","ffb74d","ffa726","ff9800","fb8c00","f57c00","ef6c00","e65100","ffd180","ffab40","ff9100","ff6d00","fbe9e7","ffccbc","ffab91","ff8a65","ff7043","ff5722","f4511e","e64a19","d84315","bf360c","ff9e80","ff6e40","ff3d00","dd2c00","efebe9","d7ccc8","bcaaa4","a1887f","8d6e63","795548","6d4c41","5d4037","4e342e","3e2723","fafafa","f5f5f5","eeeeee","e0e0e0","bdbdbd","9e9e9e","757575","616161","424242","212121","eceff1","cfd8dc","b0bec5","90a4ae","78909c","607d8b","546e7a","455a64","37474f","263238"],"simple":["ffffff","d4d4d4","a1a1a1","787878","545454","303030","000000","edc5c5","e68383","ff0000","de2424","ad3636","823737","592b2b","f5d2ee","eb8dd7","f700b9","bf1f97","9c277f","732761","4f2445","e2bcf7","bf79e8","9d00ff","8330ba","6d3096","502c69","351b47","c5c3f0","736feb","0905f7","2e2eb0","2d2d80","252554","090936","c7e2ed","6ac3e6","00bbff","279ac4","347c96","2d5b6b","103947","bbf0d9","6febb3","00ff88","2eb878","349166","2b694c","0c3d25","c2edc0","76ed70","0dff00","36c72c","408c3b","315c2e","144511","d6edbb","b5eb73","8cff00","89c93a","6f8f44","4b632a","2a400c","f1f2bf","eef069","ffff00","baba30","91913f","5e5e2b","3b3b09","ffdeb8","f2ae61","ff8400","c48037","85623d","573e25","3d2309","fcbbae","ff8066","ff2b00","cc553d","9c5b4e","61372e","36130b"],"common":["000000","ffffff","464646","b4b4b4","990030","9c5a3c","ed1c24","ffa3b1","ff7e00","e5aa7a","ffc20e","f5e49c","fff200","fff9bd","a8e61d","d3f9bc","22b14c","00b7ef","99d9ea","4d6df3","709ad1","2f3699","546d8e","6f3198","b5a5d5"],"skin tones":["ffe0bd","ffdbac","ffcd94","eac086","e0ac69","f1c27d","ffad60","c68642","8d5524","896347","765339","613D24","4C2D17","391E0B","351606","2D1304","180A01","090300"]},"colorSelected":"common","frames":[{"name":"","speed":100,"layers":[{"id":0,"src":"data:image/pngp98kjasdnasd983/24kasdjasdbase64,iVBORw0KGgoAAAANSUhEUgAAAAoAAAAKCAYAAACNMs+9AAAAAXNSR0IArs4c6QAAAFJJREFUKFOVkFEKACAIQ9v9D20oTZaGUF82X2MNZmZLDoC4FXm5mqAvFeTsDwMk8AKpNbDGaGDYn3yEmdP1dFSnOv+Dkxt3WY/+nHm1havHyXkDu/U//qSlV/cAAAAASUVORK5CYII=","edit":false,"name":"Background","opacity":"1","active":true,"unqid":"48edlo","options":{"blend":"source-over","locked":false,"filter":{"brightness":"100%","contrast":"100%","grayscale":"0%","blur":0,"dropshadow_x":0,"dropshadow_y":0,"dropshadow_blur":0,"dropshadow_alpha":1}}}],"active":true,"selectedLayer":0,"unqid":"s21ut","preview":"data:image/pngp98kjasdnasd983/24kasdjasdbase64,iVBORw0KGgoAAAANSUhEUgAAAAoAAAAKCAYAAACNMs+9AAAAAXNSR0IArs4c6QAAAFJJREFUKFOVkFEKACAIQ9v9D20oTZaGUF82X2MNZmZLDoC4FXm5mqAvFeTsDwMk8AKpNbDGaGDYn3yEmdP1dFSnOv+Dkxt3WY/+nHm1havHyXkDu/U//qSlV/cAAAAASUVORK5CYII=","width":10,"height":10}],"currentFrame":0,"speed":100,"name":"Untitled","preview":"data:image/pngp98kjasdnasd983/24kasdjasdbase64,iVBORw0KGgoAAAANSUhEUgAAAAoA/sfR5H8Fkddasdmnacvx/AAAKCAYAAACNMs+9AAAAAXNSR0IArs4c6QAAAFJJREFUKFOVkFEKACAIQ9v9D20oTZaGUF82X2MNZmZLDoC4FXm5mqAvFeTsDwMk8AKpNbDGaGDYn3yEmdP1dFSnOv+Dkxt3WY/+nHm1havHyXkDu/U//qSlV/cAAAAASUVORK5CYII=","previewApp":"","art_edit_id":0,"palette_id":false,"created_at":1679704697639,"updated_at":1679704697639,"persLayers":true,"id":1679704306706} -------------------------------------------------------------------------------- /app.c: -------------------------------------------------------------------------------- 1 | #include 2 | #include 3 | #include 4 | #include 5 | #include 6 | #include 7 | #include 8 | #include 9 | #include 10 | 11 | // ./fbt.cmd launch_app APPSRC=servotester 12 | 13 | #define SCREEN_XRES 128 14 | #define SCREEN_YRES 64 15 | #define MIN_ANGLE 0 16 | #define MAX_ANGLE 180 17 | #define FREQ 50 18 | 19 | typedef enum Mode 20 | { 21 | Manual = 1, 22 | Center = 2, 23 | Sweep = 3, 24 | } Mode; 25 | 26 | typedef struct App 27 | { 28 | Gui *gui; 29 | ViewPort *view_port; 30 | FuriMessageQueue *event_queue; 31 | 32 | int running; 33 | Mode mode; 34 | uint8_t angle; 35 | 36 | FuriHalPwmOutputId ch_prev; 37 | FuriHalPwmOutputId ch; 38 | } App; 39 | 40 | void input_callback(InputEvent *input_event, void *ctx) 41 | { 42 | App *app = ctx; 43 | furi_message_queue_put(app->event_queue, input_event, FuriWaitForever); 44 | } 45 | 46 | uint32_t angle_to_compare(uint8_t angle) 47 | { 48 | /* 49 | 1%: freq_div: 1280000, prescaler: 19, period: 64000, compare: 640 50 | 3%: freq_div: 1280000, prescaler: 19, period: 64000, compare: 1920 51 | 5%: freq_div: 1280000, prescaler: 19, period: 64000, compare: 3200 52 | 7.5%: freq_div: 1280000, prescaler: 19, period: 64000, compare: 4800 53 | 7%: freq_div: 1280000, prescaler: 19, period: 64000, compare: 4480 54 | 10%: freq_div: 1280000, prescaler: 19, period: 64000, compare: 6400 55 | 13%: freq_div: 1280000, prescaler: 19, period: 64000, compare: 8320 56 | 15%: freq_div: 1280000, prescaler: 19, period: 64000, compare: 9600 57 | 20%: freq_div: 1280000, prescaler: 19, period: 64000, compare: 12800 58 | */ 59 | 60 | uint32_t min_compare = 1920; 61 | uint32_t max_compare = 8320; 62 | 63 | if (angle == MIN_ANGLE) 64 | return min_compare; 65 | if (angle == MAX_ANGLE) 66 | return max_compare; 67 | 68 | return min_compare + floor(((float)angle / (float)MAX_ANGLE) * (max_compare - min_compare)); 69 | } 70 | 71 | void render_callback(Canvas *const canvas, void *ctx) 72 | { 73 | App *app = ctx; 74 | 75 | canvas_set_color(canvas, ColorWhite); 76 | canvas_draw_box(canvas, 0, 0, SCREEN_XRES - 1, SCREEN_YRES - 1); 77 | canvas_set_color(canvas, ColorBlack); 78 | canvas_set_font(canvas, FontSecondary); 79 | 80 | uint8_t mode_base_x = 20; 81 | canvas_draw_str(canvas, mode_base_x, 10, "Mode"); 82 | canvas_draw_str(canvas, mode_base_x, 30, "Manual"); 83 | canvas_draw_str(canvas, mode_base_x, 45, "Center"); 84 | canvas_draw_str(canvas, mode_base_x, 60, "Sweep"); 85 | 86 | uint8_t selector_base_x = 5; 87 | uint8_t r = 4; 88 | uint8_t correction = r; 89 | if (app->mode == Manual) 90 | { 91 | canvas_draw_disc(canvas, selector_base_x, 30 - correction, r); 92 | } 93 | else 94 | { 95 | canvas_draw_circle(canvas, selector_base_x, 30 - correction, r); 96 | } 97 | 98 | if (app->mode == Center) 99 | { 100 | canvas_draw_disc(canvas, selector_base_x, 45 - correction, r); 101 | } 102 | else 103 | { 104 | canvas_draw_circle(canvas, selector_base_x, 45 - correction, r); 105 | } 106 | 107 | if (app->mode == Sweep) 108 | { 109 | canvas_draw_disc(canvas, selector_base_x, 60 - correction, r); 110 | } 111 | else 112 | { 113 | canvas_draw_circle(canvas, selector_base_x, 60 - correction, r); 114 | } 115 | 116 | uint8_t angle_base_x = 70; 117 | canvas_draw_str(canvas, angle_base_x, 10, "Angle"); 118 | canvas_draw_circle(canvas, angle_base_x + 18, 21, 2); 119 | 120 | char angle_str[32]; 121 | snprintf(angle_str, sizeof(angle_str), "%3u", app->angle); 122 | canvas_draw_str(canvas, angle_base_x, 30, angle_str); 123 | } 124 | 125 | App *app_alloc() 126 | { 127 | App *app = malloc(sizeof(App)); 128 | 129 | app->gui = furi_record_open(RECORD_GUI); 130 | app->view_port = view_port_alloc(); 131 | view_port_draw_callback_set(app->view_port, render_callback, app); 132 | view_port_input_callback_set(app->view_port, input_callback, app); 133 | gui_add_view_port(app->gui, app->view_port, GuiLayerFullscreen); 134 | app->event_queue = furi_message_queue_alloc(8, sizeof(InputEvent)); 135 | 136 | app->running = 1; 137 | app->mode = Manual; 138 | app->ch = FuriHalPwmOutputIdTim1PA7; // A7 139 | app->angle = 0; 140 | 141 | return app; 142 | } 143 | 144 | void app_free(App *app) 145 | { 146 | furi_assert(app); 147 | 148 | furi_hal_pwm_stop(app->ch); 149 | 150 | view_port_enabled_set(app->view_port, false); 151 | gui_remove_view_port(app->gui, app->view_port); 152 | view_port_free(app->view_port); 153 | furi_record_close(RECORD_GUI); 154 | furi_message_queue_free(app->event_queue); 155 | app->gui = NULL; 156 | 157 | free(app); 158 | } 159 | 160 | void update_state(App *app, InputKey key) 161 | { 162 | if (key == InputKeyUp) 163 | { 164 | if (app->mode == Manual) 165 | { 166 | app->angle = MIN_ANGLE; 167 | app->mode = Sweep; 168 | } 169 | else if (app->mode == Center) 170 | { 171 | app->mode = Manual; 172 | } 173 | else if (app->mode == Sweep) 174 | { 175 | app->mode = Center; 176 | } 177 | } 178 | 179 | if (key == InputKeyDown) 180 | { 181 | if (app->mode == Manual) 182 | { 183 | app->mode = Center; 184 | } 185 | else if (app->mode == Center) 186 | { 187 | app->angle = MIN_ANGLE; 188 | app->mode = Sweep; 189 | } 190 | else if (app->mode == Sweep) 191 | { 192 | app->mode = Manual; 193 | } 194 | } 195 | 196 | if (app->mode == Manual) 197 | { 198 | if (key == InputKeyRight) 199 | { 200 | if (app->angle < MAX_ANGLE) 201 | { 202 | app->angle = app->angle + 10; 203 | } 204 | } 205 | 206 | if (key == InputKeyLeft) 207 | { 208 | if (app->angle > MIN_ANGLE) 209 | { 210 | app->angle = app->angle - 10; 211 | } 212 | } 213 | } 214 | } 215 | 216 | void custom_pwm_set_params(uint32_t freq, uint32_t compare) 217 | { 218 | furi_assert(freq > 0); 219 | uint32_t freq_div = 64000000LU / freq; 220 | 221 | uint32_t prescaler = freq_div / 0x10000LU; 222 | uint32_t period = freq_div / (prescaler + 1); 223 | // uint32_t compare = period * duty / 100; 224 | 225 | LL_TIM_SetPrescaler(TIM1, prescaler); 226 | LL_TIM_SetAutoReload(TIM1, period - 1); 227 | LL_TIM_OC_SetCompareCH1(TIM1, compare); 228 | } 229 | 230 | void tick(void *ctx) 231 | { 232 | App *app = ctx; 233 | 234 | if (!app->running) 235 | return; 236 | if (app->mode != Sweep) 237 | return; 238 | 239 | if (app->angle == MAX_ANGLE) 240 | { 241 | app->angle = MIN_ANGLE; 242 | } 243 | else if (app->angle == MIN_ANGLE) 244 | { 245 | app->angle = MAX_ANGLE; 246 | } 247 | 248 | custom_pwm_set_params(FREQ, angle_to_compare(app->angle)); 249 | } 250 | 251 | int32_t servotester_app_entry(void *p) 252 | { 253 | UNUSED(p); 254 | 255 | App *app = app_alloc(); 256 | 257 | furi_hal_pwm_start(app->ch, FREQ, angle_to_compare(app->angle)); 258 | custom_pwm_set_params(FREQ, angle_to_compare(app->angle)); 259 | 260 | FuriTimer *timer = furi_timer_alloc(tick, FuriTimerTypePeriodic, app); 261 | furi_timer_start(timer, furi_kernel_get_tick_frequency() / 1); 262 | 263 | InputEvent input; 264 | while (app->running) 265 | { 266 | FuriStatus qstat = furi_message_queue_get(app->event_queue, &input, 100); 267 | if (qstat == FuriStatusOk) 268 | { 269 | if (input.key == InputKeyBack) 270 | { 271 | app->running = 0; 272 | // TODO: handle InputTypeLong 273 | } 274 | else if (input.type == InputTypePress) 275 | { 276 | update_state(app, input.key); 277 | 278 | if (!app->running) 279 | { 280 | break; 281 | } 282 | 283 | switch (app->mode) 284 | { 285 | case Manual: 286 | custom_pwm_set_params(FREQ, angle_to_compare(app->angle)); 287 | break; 288 | case Center: 289 | app->angle = 90; 290 | custom_pwm_set_params(FREQ, angle_to_compare(app->angle)); 291 | break; 292 | case Sweep: 293 | // handled in ticks 294 | break; 295 | } 296 | } 297 | } 298 | } 299 | 300 | furi_timer_free(timer); 301 | app_free(app); 302 | 303 | return 0; 304 | } --------------------------------------------------------------------------------