diff --git a/.github/workflows/docker-build-action.yaml b/.github/workflows/docker-build-action.yaml index 7a4c9d2..75e736d 100644 --- a/.github/workflows/docker-build-action.yaml +++ b/.github/workflows/docker-build-action.yaml @@ -24,4 +24,4 @@ jobs: with: context: . push: true - tags: thingpulse/esp-iot-flasher:latest \ No newline at end of file + tags: thingpulse/esp-app-market:latest \ No newline at end of file diff --git a/Dockerfile b/Dockerfile index b4bb5ff..1faa793 100644 --- a/Dockerfile +++ b/Dockerfile @@ -6,5 +6,5 @@ RUN npm install RUN npm run build --prod #stage 2 FROM nginx:alpine -COPY --from=node /app/dist/esp-iot-flasher /usr/share/nginx/html +COPY --from=node /app/dist/esp-app-market /usr/share/nginx/html COPY /nginx.conf /etc/nginx/conf.d/default.conf \ No newline at end of file diff --git a/README.md b/README.md index 1178957..e742de2 100644 --- a/README.md +++ b/README.md @@ -1,12 +1,7 @@ -# ESP-IoT-Flasher +# ESP-Firmware-Store -The ESP-IoT-Flasher is web-based tool which is made for simplicity of use. -A device tester just needs to install the UART driver. After opening the application -in his browser he selects the device and clicks `Flash & Test`. The web application -then flashes a firmware to the device and the device responds with tests results from tests -running on the device. +The ESP-Firmware-Store is web-based tool which is made for simplicity of use. -[![Running the test](https://img.youtube.com/vi/a3fYCeyGAyI/maxresdefault.jpg)](https://www.youtube.com/shorts/a3fYCeyGAyI) ## Development server @@ -24,11 +19,11 @@ Run `ng test` to execute the unit tests via [Karma](https://karma-runner.github. ### Building docker for local machine architecture -`docker build -t thingpulse/esp-iot-flasher:1.0.2 . ` +`docker build -t thingpulse/esp-app-market:1.0.2 . ` ### building docker on ARM for x86 -`docker buildx build --platform linux/amd64 -t thingpulse/esp-iot-flasher:1.0.2 .` +`docker buildx build --platform linux/amd64 -t thingpulse/esp-app-market:1.0.2 .` ### Running with docker-compose @@ -40,9 +35,7 @@ will start the service at http://localhost:8081 ## Changing device configuration Default configurations loads the browser from the server. The angular application looks for a configuration -file at `/assets/defaultDeviceConfiguration.json`: - -https://github.com/ThingPulse/esp-iot-flasher/blob/1c669c1fe53238a759b6027cf89888fffa4055e9/src/assets/defaultDeviceConfiguration.json#L1-L24 +file at `/assets/defaultDeviceConfiguration.json` ### Explanation @@ -66,7 +59,7 @@ Adapt and uncomment the following lines to use your own configuration files in ` ## Creating firmware to run the test The following repository shows how to build a firmware which can be used together with the -esp-iot-flasher: https://github.com/ThingPulse/esp32-epulse-feather-testbed +esp-app-market: https://github.com/ThingPulse/esp32-epulse-feather-testbed ## FAQ diff --git a/angular.json b/angular.json index f1c138b..c68a004 100644 --- a/angular.json +++ b/angular.json @@ -3,7 +3,7 @@ "version": 1, "newProjectRoot": "projects", "projects": { - "esp-iot-flasher": { + "esp-app-market": { "projectType": "application", "schematics": {}, "root": "", @@ -13,7 +13,7 @@ "build": { "builder": "@angular-devkit/build-angular:browser", "options": { - "outputPath": "dist/esp-iot-flasher", + "outputPath": "dist/esp-app-market", "index": "src/index.html", "main": "src/main.ts", "polyfills": [ @@ -61,10 +61,10 @@ "builder": "@angular-devkit/build-angular:dev-server", "configurations": { "production": { - "browserTarget": "esp-iot-flasher:build:production" + "browserTarget": "esp-app-market:build:production" }, "development": { - "browserTarget": "esp-iot-flasher:build:development" + "browserTarget": "esp-app-market:build:development" } }, "defaultConfiguration": "development" @@ -72,7 +72,7 @@ "extract-i18n": { "builder": "@angular-devkit/build-angular:extract-i18n", "options": { - "browserTarget": "esp-iot-flasher:build" + "browserTarget": "esp-app-market:build" } }, "test": { diff --git a/docker-compose.yaml b/docker-compose.yaml index d585433..f13e296 100644 --- a/docker-compose.yaml +++ b/docker-compose.yaml @@ -1,8 +1,8 @@ version: '2' services: message-server: - container_name: esp-iot-flasher - image: thingpulse/esp-iot-flasher:1.0.8 + container_name: esp-app-market + image: thingpulse/esp-app-market:1.0.8 restart: always ports: - 8081:80 diff --git a/package-lock.json b/package-lock.json index e63a09b..07a1566 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,11 +1,11 @@ { - "name": "esp-iot-flasher", + "name": "esp-app-market", "version": "0.0.0", "lockfileVersion": 3, "requires": true, "packages": { "": { - "name": "esp-iot-flasher", + "name": "esp-app-market", "version": "0.0.0", "dependencies": { "@angular/animations": "^15.0.0", diff --git a/package.json b/package.json index 638d583..417b44a 100644 --- a/package.json +++ b/package.json @@ -1,5 +1,5 @@ { - "name": "esp-iot-flasher", + "name": "esp-app-market", "version": "0.0.0", "scripts": { "ng": "ng", diff --git a/src/app/app.component.html b/src/app/app.component.html index 1c06818..4057d93 100644 --- a/src/app/app.component.html +++ b/src/app/app.component.html @@ -2,8 +2,8 @@ - Firmware Store - share Github + ESP App Market + share Github diff --git a/src/app/app.component.spec.ts b/src/app/app.component.spec.ts index 05c0d7d..10f7cd1 100644 --- a/src/app/app.component.spec.ts +++ b/src/app/app.component.spec.ts @@ -16,16 +16,16 @@ describe('AppComponent', () => { expect(app).toBeTruthy(); }); - it(`should have as title 'esp-iot-flasher'`, () => { + it(`should have as title 'esp-app-market'`, () => { const fixture = TestBed.createComponent(AppComponent); const app = fixture.componentInstance; - expect(app.title).toEqual('esp-iot-flasher'); + expect(app.title).toEqual('esp-app-market'); }); it('should render title', () => { const fixture = TestBed.createComponent(AppComponent); fixture.detectChanges(); const compiled = fixture.nativeElement as HTMLElement; - expect(compiled.querySelector('.content span')?.textContent).toContain('esp-iot-flasher app is running!'); + expect(compiled.querySelector('.content span')?.textContent).toContain('esp-app-market app is running!'); }); }); diff --git a/src/app/app.module.ts b/src/app/app.module.ts index 340f395..58184e7 100644 --- a/src/app/app.module.ts +++ b/src/app/app.module.ts @@ -6,14 +6,26 @@ import { BrowserAnimationsModule } from '@angular/platform-browser/animations'; import { FormsModule } from '@angular/forms'; import { MaterialModule } from './material/material.module'; import { RouterModule, Routes } from '@angular/router'; +import { DevicesComponent } from './devices/devices.component'; +import { AppsComponent } from './apps/apps.component'; +import { CacheInterceptor } from './services/cache.interceptor'; +import { HTTP_INTERCEPTORS } from '@angular/common/http'; +import { FlasherComponent } from './flasher/flasher.component'; const routes: Routes = [ - { path: '**', redirectTo: '/', pathMatch: 'full' } + { path: '', component: DevicesComponent }, + { path: 'device/:deviceId', component: AppsComponent }, + { path: 'device/:deviceId/app/:appId', component: FlasherComponent }, + { path: '**', redirectTo: '/', pathMatch: 'full' }, + ]; @NgModule({ declarations: [ - AppComponent + AppComponent, + DevicesComponent, + AppsComponent, + FlasherComponent ], imports: [ BrowserModule, @@ -23,7 +35,9 @@ const routes: Routes = [ FormsModule, RouterModule.forRoot(routes) ], - providers: [], + providers: [ + { provide: HTTP_INTERCEPTORS, useClass: CacheInterceptor, multi: true }, + ], bootstrap: [AppComponent] }) export class AppModule { } diff --git a/src/app/apps/apps.component.css b/src/app/apps/apps.component.css new file mode 100644 index 0000000..8bed319 --- /dev/null +++ b/src/app/apps/apps.component.css @@ -0,0 +1,3 @@ +.mdc-card { + margin-bottom: 10px; +} \ No newline at end of file diff --git a/src/app/apps/apps.component.html b/src/app/apps/apps.component.html new file mode 100644 index 0000000..f373ebe --- /dev/null +++ b/src/app/apps/apps.component.html @@ -0,0 +1,25 @@ +

{{device?.name}}

+ + + Device + {{device?.name}} + {{device?.manufacturer}} + + + + {{device?.description}} + + + + + + Device + {{app.name}} + {{app?.version}} + + {{app?.description}} + + + + + diff --git a/src/app/apps/apps.component.spec.ts b/src/app/apps/apps.component.spec.ts new file mode 100644 index 0000000..950d321 --- /dev/null +++ b/src/app/apps/apps.component.spec.ts @@ -0,0 +1,23 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; + +import { AppsComponent } from './apps.component'; + +describe('AppsComponent', () => { + let component: AppsComponent; + let fixture: ComponentFixture; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + declarations: [ AppsComponent ] + }) + .compileComponents(); + + fixture = TestBed.createComponent(AppsComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/src/app/apps/apps.component.ts b/src/app/apps/apps.component.ts new file mode 100644 index 0000000..2318d0a --- /dev/null +++ b/src/app/apps/apps.component.ts @@ -0,0 +1,34 @@ +import { Component, OnInit } from '@angular/core'; +import { ActivatedRoute } from '@angular/router'; +import { DevicesService } from '../services/devices.service'; +import { Device } from '../models/device'; +import { AppsService } from '../services/apps.service'; +import { App } from '../models/app'; + +@Component({ + selector: 'app-apps', + templateUrl: './apps.component.html', + styleUrls: ['./apps.component.css'] +}) +export class AppsComponent implements OnInit{ + + deviceId: string; + device: Device | undefined; + apps: App[]; + + constructor(private route: ActivatedRoute, + public deviceService: DevicesService, + public appService: AppsService ) { } + + + ngOnInit(): void { + this.deviceId = this.route.snapshot.paramMap.get("deviceId")!; + console.log(this.deviceId); + this.deviceService.findById(this.deviceId).subscribe((device) => { this.device = device; }) + console.log(this.device); + this.appService.findByDeviceId(this.deviceId).subscribe((apps) => { + this.apps = apps; + }); + } + +} diff --git a/src/app/devices/devices.component.css b/src/app/devices/devices.component.css new file mode 100644 index 0000000..e69de29 diff --git a/src/app/devices/devices.component.html b/src/app/devices/devices.component.html new file mode 100644 index 0000000..f5e9e36 --- /dev/null +++ b/src/app/devices/devices.component.html @@ -0,0 +1,13 @@ + + + Device + {{device.name}} + {{device.manufacturer}} + + + + {{device.description}} + + + + diff --git a/src/app/devices/devices.component.spec.ts b/src/app/devices/devices.component.spec.ts new file mode 100644 index 0000000..c31f7dd --- /dev/null +++ b/src/app/devices/devices.component.spec.ts @@ -0,0 +1,23 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; + +import { DevicesComponent } from './devices.component'; + +describe('DevicesComponent', () => { + let component: DevicesComponent; + let fixture: ComponentFixture; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + declarations: [ DevicesComponent ] + }) + .compileComponents(); + + fixture = TestBed.createComponent(DevicesComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/src/app/devices/devices.component.ts b/src/app/devices/devices.component.ts new file mode 100644 index 0000000..888b5ca --- /dev/null +++ b/src/app/devices/devices.component.ts @@ -0,0 +1,22 @@ +import { Component, OnInit } from '@angular/core'; +import { DevicesService } from '../services/devices.service'; +import { Device } from '../models/device'; + +@Component({ + selector: 'app-devices', + templateUrl: './devices.component.html', + styleUrls: ['./devices.component.css'] +}) +export class DevicesComponent implements OnInit { + + devices: Device[] = []; + + constructor(public deviceService: DevicesService) { } + + ngOnInit(): void { + this.deviceService.getDevices().subscribe((devices) => { + this.devices = devices; + }); + } + +} diff --git a/src/app/flasher/flasher.component.css b/src/app/flasher/flasher.component.css new file mode 100644 index 0000000..0e83513 --- /dev/null +++ b/src/app/flasher/flasher.component.css @@ -0,0 +1,19 @@ +.flex-container { + display: flex; + justify-content: space-around; + flex-wrap: wrap; + padding: 16px; + } + +.flex-row { + display: flex; /* or inline-flex */ + padding-right: 10px; + gap: 20px; + align-items: baseline; + justify-content: space-between; + } + + .mdc-card { + margin-bottom: 10px; +} + diff --git a/src/app/flasher/flasher.component.html b/src/app/flasher/flasher.component.html new file mode 100644 index 0000000..5a13bf6 --- /dev/null +++ b/src/app/flasher/flasher.component.html @@ -0,0 +1,57 @@ + + + Device + {{app?.name}} + {{app?.version}} + + + {{app?.description}} + {{app?.instructions}} + + +
{{flasherConsole}}
+
+
+ {{ partition.name }} {{progresses[i].progress}}% + +
+
+ + +
+ + + + + + +
+
+ +
+
+ phonelink Connected +
+
+ phonelink_off Disconnected +
+
+ + +
+
+ + + + + + Serial Console Output + + + + Messages: {{messageCount}} + + + + \ No newline at end of file diff --git a/src/app/flasher/flasher.component.spec.ts b/src/app/flasher/flasher.component.spec.ts new file mode 100644 index 0000000..076c7b7 --- /dev/null +++ b/src/app/flasher/flasher.component.spec.ts @@ -0,0 +1,23 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; + +import { FlasherComponent } from './flasher.component'; + +describe('FlasherComponent', () => { + let component: FlasherComponent; + let fixture: ComponentFixture; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + declarations: [ FlasherComponent ] + }) + .compileComponents(); + + fixture = TestBed.createComponent(FlasherComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/src/app/flasher/flasher.component.ts b/src/app/flasher/flasher.component.ts new file mode 100644 index 0000000..af15388 --- /dev/null +++ b/src/app/flasher/flasher.component.ts @@ -0,0 +1,138 @@ +import { Component, OnInit } from '@angular/core'; +import { Device } from '../models/device'; +import { ActivatedRoute } from '@angular/router'; +import { AppsService } from '../services/apps.service'; +import { DevicesService } from '../services/devices.service'; +import { App } from '../models/app'; +import { EspPortService } from '../services/esp-port.service'; +import { PartitionProgress } from '../services/utils.service'; +import { Subscription } from 'rxjs'; + +@Component({ + selector: 'app-flasher', + templateUrl: './flasher.component.html', + styleUrls: ['./flasher.component.css'] +}) +export class FlasherComponent implements OnInit{ + + deviceId: string; + appId: string; + device: Device | undefined; + app: App | undefined; + + private subscriptions: Subscription = new Subscription(); + + private connected = false; + private monitoring = false; + + messageArea: string = ""; + messageCount = 0; + flasherConsole: string; + progresses: PartitionProgress[] = new Array(); + + constructor(private route: ActivatedRoute, + public deviceService: DevicesService, + public appService: AppsService, + public portService: EspPortService ) { } + + + ngOnInit(): void { + this.deviceId = this.route.snapshot.paramMap.get("deviceId")!; + this.appId = this.route.snapshot.paramMap.get("appId")!; + console.log(this.deviceId); + this.deviceService.findById(this.deviceId).subscribe((device) => { this.device = device; }) + console.log(this.device); + this.appService.findById(this.appId).subscribe((app) => { + this.app = app; + }); + const portStateStreamSubscription = this.portService.portStateStream.subscribe(isConnected => { + console.log("isConnected: ", isConnected); + this.connected = isConnected; + }); + this.subscriptions.add(portStateStreamSubscription); + const monitorStateSubscription = this.portService.monitorStateStream.subscribe(isMonitoring => { + console.log("isMonitoring: ", isMonitoring); + this.monitoring = isMonitoring; + }); + this.subscriptions.add(monitorStateSubscription); + + const monitorStreamSubscription = this.portService.monitorMessageStream.subscribe(message => { + + this.messageArea = this.messageArea + '\n' + message; + this.messageCount++; + + + }); + this.subscriptions.add(monitorStreamSubscription); + + const flashProgressStreamSubscription = this.portService.flashProgressStream.subscribe(progress => { + this.progresses[progress.index] = progress; + }); + this.subscriptions.add(flashProgressStreamSubscription); + + const testStateStreamSubscription = this.portService.testStateStream.subscribe(state => { + console.log("Test State: ", state); + }); + this.subscriptions.add(testStateStreamSubscription); + } + + resetState() { + this.messageArea = "" + this.messageCount = 0; + this.flasherConsole = "Ready"; + this.progresses = new Array(this.app?.partitions.length); + } + + connect() { + try { + this.portService.connect(); + } catch (e) { + this.flasherConsole = "Could not open port. Please close all open monitoring sessions or refresh this browser."; + } + } + + close() { + this.portService.close(); + } + + isConnected() { + return this.connected; + } + + isMonitoring() { + return this.monitoring; + } + + startMonitor() { + this.portService.startMonitor(); + } + + stopMonitor() { + this.portService.stopMonitor(); + } + + reset() { + this.portService.resetDevice(); + } + + async flash() { + console.log("Flashing"); + this.resetState(); + if (this.app && this.app.partitions) { + try { + await this.portService.connect(); + console.log("Connected"); + } catch (e) { + console.log(e); + //this.flasherConsole = "Could not open port. Please close all open monitoring sessions or refresh this browser."; + return; + } + console.log("Flashing"); + await this.portService.flash(this.app.partitions); + } else { + console.log("No app selected"); + } + + } + +} diff --git a/src/app/models/app.ts b/src/app/models/app.ts new file mode 100644 index 0000000..b80f1a2 --- /dev/null +++ b/src/app/models/app.ts @@ -0,0 +1,13 @@ +import { Partition } from "../services/utils.service"; + +export interface App { + id: string; + name: string; + description: string; + version: string; + repository: string; + appIcon: string; + instructions: string; + supportedDevices: string[]; + partitions: Partition[]; +} \ No newline at end of file diff --git a/src/app/models/device.ts b/src/app/models/device.ts new file mode 100644 index 0000000..c9bcf46 --- /dev/null +++ b/src/app/models/device.ts @@ -0,0 +1,8 @@ +export interface Device { + id: string; + name: string; + manufacturer: string; + imageThumbnail: string; + productLink: string; + description: string; +} diff --git a/src/app/services/apps.service.spec.ts b/src/app/services/apps.service.spec.ts new file mode 100644 index 0000000..ae06cee --- /dev/null +++ b/src/app/services/apps.service.spec.ts @@ -0,0 +1,16 @@ +import { TestBed } from '@angular/core/testing'; + +import { AppsService } from './apps.service'; + +describe('AppsService', () => { + let service: AppsService; + + beforeEach(() => { + TestBed.configureTestingModule({}); + service = TestBed.inject(AppsService); + }); + + it('should be created', () => { + expect(service).toBeTruthy(); + }); +}); diff --git a/src/app/services/apps.service.ts b/src/app/services/apps.service.ts new file mode 100644 index 0000000..3570f9a --- /dev/null +++ b/src/app/services/apps.service.ts @@ -0,0 +1,31 @@ +import { HttpClient } from '@angular/common/http'; +import { Injectable } from '@angular/core'; +import { App } from '../models/app'; +import { Observable, map } from 'rxjs'; + +@Injectable({ + providedIn: 'root' +}) +export class AppsService { + + constructor(public httpClient: HttpClient) { + + } + + getApps(): Observable { + return this.httpClient.get('/assets/apps.json'); + } + + findByDeviceId(deviceId: string): Observable { + return this.getApps().pipe( + map(apps => apps.filter(app => app.supportedDevices.includes(deviceId))) + ); + } + + + findById(id: string): Observable { + return this.getApps().pipe( + map(apps => apps.find(app => app.id === id)) + ); + } +} diff --git a/src/app/services/cache.interceptor.ts b/src/app/services/cache.interceptor.ts new file mode 100644 index 0000000..5a46e5d --- /dev/null +++ b/src/app/services/cache.interceptor.ts @@ -0,0 +1,31 @@ +import { Injectable } from '@angular/core'; +import { HttpEvent, HttpInterceptor, HttpHandler, HttpRequest, HttpResponse } from '@angular/common/http'; +import { Observable, of } from 'rxjs'; +import { tap } from 'rxjs/operators'; + +@Injectable() +export class CacheInterceptor implements HttpInterceptor { + private cache = new Map>(); + + intercept(req: HttpRequest, next: HttpHandler): Observable> { + // Only cache GET requests + if (req.method !== 'GET') { + return next.handle(req); + } + + // Check if the request is in the cache + const cachedResponse = this.cache.get(req.url); + if (cachedResponse) { + return of(cachedResponse.clone()); + } + + // If the request is not in the cache, send it to the server and cache the response + return next.handle(req).pipe( + tap(event => { + if (event instanceof HttpResponse) { + this.cache.set(req.url, event.clone()); + } + }) + ); + } +} diff --git a/src/app/services/devices.service.spec.ts b/src/app/services/devices.service.spec.ts new file mode 100644 index 0000000..69047c1 --- /dev/null +++ b/src/app/services/devices.service.spec.ts @@ -0,0 +1,16 @@ +import { TestBed } from '@angular/core/testing'; + +import { DevicesService } from './devices.service'; + +describe('DevicesService', () => { + let service: DevicesService; + + beforeEach(() => { + TestBed.configureTestingModule({}); + service = TestBed.inject(DevicesService); + }); + + it('should be created', () => { + expect(service).toBeTruthy(); + }); +}); diff --git a/src/app/services/devices.service.ts b/src/app/services/devices.service.ts new file mode 100644 index 0000000..2c74981 --- /dev/null +++ b/src/app/services/devices.service.ts @@ -0,0 +1,26 @@ +import { HttpClient } from '@angular/common/http'; +import { Injectable, OnInit } from '@angular/core'; +import { Device } from '../models/device'; +import { Observable, map, of } from 'rxjs'; + +@Injectable({ + providedIn: 'root' +}) +export class DevicesService { + + constructor(public httpClient: HttpClient) { + + } + + + getDevices(): Observable { + return this.httpClient.get('/assets/devices.json'); + } + + + findById(id: string): Observable { + return this.getDevices().pipe( + map(devices => devices.find(device => device.id === id)) + ); + } +} diff --git a/src/app/services/esp-port.service.spec.ts b/src/app/services/esp-port.service.spec.ts new file mode 100644 index 0000000..99627e5 --- /dev/null +++ b/src/app/services/esp-port.service.spec.ts @@ -0,0 +1,16 @@ +import { TestBed } from '@angular/core/testing'; + +import { EspPortService } from './esp-port.service'; + +describe('EspPortService', () => { + let service: EspPortService; + + beforeEach(() => { + TestBed.configureTestingModule({}); + service = TestBed.inject(EspPortService); + }); + + it('should be created', () => { + expect(service).toBeTruthy(); + }); +}); diff --git a/src/app/services/esp-port.service.ts b/src/app/services/esp-port.service.ts new file mode 100644 index 0000000..3995cc5 --- /dev/null +++ b/src/app/services/esp-port.service.ts @@ -0,0 +1,301 @@ +import { HttpClient } from '@angular/common/http'; +import { Injectable } from '@angular/core'; + +import { ESPLoader, FlashOptions, IEspLoaderTerminal, LoaderOptions, Transport } from "esptool-js"; +import { firstValueFrom, Subject } from 'rxjs'; +import { LineBreakTransformer, Partition, PartitionProgress, sleep, TestState } from './utils.service'; +import { MD5, enc } from 'crypto-js'; + + +@Injectable({ + providedIn: 'root' +}) +export class EspPortService { + + private connected = false; + private monitorPort = true; + port!: SerialPort; + + private controlCharacter: string = "\n"; + + private transport: Transport; + private esploader: ESPLoader; + + // Publishes state changes of the selected serial port + private portStateSource = new Subject(); + portStateStream = this.portStateSource.asObservable(); + + // Publishes the state of the console monitor + private monitorStateSource = new Subject(); + monitorStateStream = this.monitorStateSource.asObservable(); + + // Publishes the console messages + private monitorMessageSource = new Subject(); + monitorMessageStream = this.monitorMessageSource.asObservable(); + + // Publishes the progress of the flashing process for each partition + private flashProgressSource = new Subject(); + flashProgressStream = this.flashProgressSource.asObservable(); + + // Publishes the progress/state of the test + private testStateSource = new Subject(); + testStateStream = this.testStateSource.asObservable(); + + // Characters contained in the first messages after reboot in an ESP32 + private resetMessageMatchers: string[] = ['rst:0x1', 'configsip', 'mode:DIO', 'entry 0x', 'READY_FOR_SELFTEST']; + private selfTestMatchers: string[] = ['READY_FOR_SELFTEST']; + + private reader!: ReadableStreamDefaultReader; + private readableStreamClosed!: any; + + private espLoaderTerminal = { + clean: () => { + this.monitorMessageSource.next("Clean"); + }, + writeLine: (data: any) => { + this.monitorMessageSource.next(data); + }, + write: (data: any) => { + this.monitorMessageSource.next(data); + }, + }; + + + constructor(public httpClient: HttpClient) { + } + + async connect() { + + // If port is still open close it first + if (this.port && this.connected) { + console.log("Port still seems to be connected. Closing"); + await this.close(); + this.setState(false); + } + + const port = await navigator.serial.requestPort(); + this.transport = new Transport(port); + try { + + const flashOptions = { + transport: this.transport, + baudrate: 115200, + terminal: this.espLoaderTerminal + + } as LoaderOptions; + this.esploader = new ESPLoader(flashOptions); + + const chip = await this.esploader.main_fn(); + console.log(this.esploader.chip); + } catch (e) { + console.error(e); + } + await this.openPort(port); + + } + + async openPort(port: SerialPort) { + this.port = port; + console.log('oppening port:', port) + this.port.addEventListener('connect', (event) => { + this.setState(true); + }); + this.port.addEventListener('disconnect', (event) => { + this.setState(false); + }); + if (!this.port.readable) { + await this.port.open({ baudRate: 115200 }); + } + const portInfo = port.getInfo(); + console.log(portInfo); + this.setState(true); + + } + + checkForRestart(message: string) { + // Check the given console message for some trigger characters + // and publish a message if that is the case + for (let matcher of this.resetMessageMatchers) { + if (message.indexOf(matcher) > -1) { + this.testStateSource.next(TestState.Restarted); + break; + } + } + + } + + checkForTesting(message: string) { + // Check the given console message for some trigger characters + // and publish a message if that is the case + for (let matcher of this.selfTestMatchers) { + if (message.indexOf(matcher) > -1) { + this.testStateSource.next(TestState.Testing); + break; + } + } + + } + + async reconnect() { + try { + await this.port.close(); + console.log("Port closed"); + this.setState(false); + await this.openPort(this.port); + } + catch (e) { + console.log('Error clossing port', this.port, e) + } + } + + setState(isConnected: boolean) { + this.connected = isConnected; + this.portStateSource.next(this.connected); + this.testStateSource.next(isConnected ? TestState.Connected : TestState.Initial); + } + + async sendSelfTestCommand() { + console.log("Sending self test command"); + const encoder = new TextEncoder(); + const writer = this.port.writable?.getWriter(); + if (writer) { + await writer.write(encoder.encode("SELFTEST\n")); + writer.releaseLock(); + } + } + + + async resetDevice() { + /* + State table of the programming circuit + DTR RTS -> EN IO0 + 1 1 1 1 + 0 0 1 1 + 1 0 0 1 + 0 1 1 0 + */ + console.log("Resetting device"); + this.testStateSource.next(TestState.Restarting); + await this.port.setSignals({ dataTerminalReady: false}); + await this.port.setSignals({ requestToSend: true}); + sleep(100); + await this.port.setSignals({ dataTerminalReady: true}); + await this.port.setSignals({ requestToSend: false}); + sleep(50); + await this.port.setSignals({ dataTerminalReady: false}); + } + + setMonitorState(isMonitoring: boolean) { + this.monitorPort = isMonitoring; + this.monitorStateSource.next(this.monitorPort); + } + + startMonitor() { + this.setMonitorState(true); + this.readLoop(); + } + + async stopMonitor() { + this.setMonitorState(false); + await this.reader.cancel(); + await this.readableStreamClosed.catch(() => { }); + } + + async readLoop() { + console.log("Is port readable: " + this.port.readable); + while (this.port.readable && this.monitorPort) { + const textDecoder = new TextDecoderStream(); + this.readableStreamClosed = this.port.readable.pipeTo(textDecoder.writable); + this.reader = textDecoder.readable + .pipeThrough(new TransformStream(new LineBreakTransformer(this.controlCharacter))) + .getReader(); + + try { + while (true) { + const { value, done } = await this.reader.read(); + if (done) { + console.log("Done reading"); + this.reader.releaseLock(); + break; + } + if (value && value !== "") { + console.log(value); + this.checkForRestart(value); + this.checkForTesting(value); + this.monitorMessageSource.next(value); + } + } + } catch (error) { + console.error("Read Loop error.", error); + } + console.log("."); + } + console.log("Leaving read loop..."); + } + + async flash(partitions: Partition[]) { + await this.loadData(partitions); + try { + console.log("connecting..."); + + + try { + const fileArray = []; + //const progressBars = []; + for (let i = 0; i < partitions.length; i++) { + fileArray.push({ data: partitions[i].data, address: partitions[i].offset }); + } + try { + const flashOptions: FlashOptions = { + fileArray: fileArray, + flashSize: "keep", + eraseAll: false, + compress: true, + reportProgress: (fileIndex, written, total) => { + this.flashProgressSource.next({index: fileIndex, progress: Math.round((written / total) * 100)}); + }, + calculateMD5Hash: (image) => MD5(enc.Latin1.parse(image)).toString(), + } as FlashOptions; + this.testStateSource.next(TestState.Flashing); + await this.esploader.write_flash(flashOptions); + } catch (e) { + console.error(e); + + } + console.log("successfully written device partitions"); + console.log("flashing succeeded"); + this.testStateSource.next(TestState.Flashed); + await this.esploader.flash_finish(false); + } finally { + await this.esploader.hard_reset(); + } + } finally { + console.log("Done flashing"); + } + } + + async loadData(partitions: Partition[]) { + this.testStateSource.next(TestState.LoadingFirmware); + await Promise.all(partitions.map(async (partition) => { + let buffer = await firstValueFrom(this.httpClient.get(partition.url, { responseType: 'arraybuffer' })); + console.log("Array Buffer Length: %d", buffer.byteLength); + var byteArray = new Uint8Array(buffer); + var decoder = new TextDecoder(); + var value: number; + for (var i = 0; i < byteArray.length; i++) { + partition.data += String.fromCharCode((byteArray.at(i) || 0)); + } + + })); + } + + async close() { + try { + await this.port.close(); + console.log("Port closed"); + this.setState(false); + } catch (e) { + console.log('Error clossing port', this.port, e) + } + } +} diff --git a/src/app/services/utils.service.spec.ts b/src/app/services/utils.service.spec.ts new file mode 100644 index 0000000..d70aee3 --- /dev/null +++ b/src/app/services/utils.service.spec.ts @@ -0,0 +1,16 @@ +import { TestBed } from '@angular/core/testing'; + +import { UtilsService } from './utils.service'; + +describe('UtilsService', () => { + let service: UtilsService; + + beforeEach(() => { + TestBed.configureTestingModule({}); + service = TestBed.inject(UtilsService); + }); + + it('should be created', () => { + expect(service).toBeTruthy(); + }); +}); diff --git a/src/app/services/utils.service.ts b/src/app/services/utils.service.ts new file mode 100644 index 0000000..c5f72bf --- /dev/null +++ b/src/app/services/utils.service.ts @@ -0,0 +1,76 @@ +import { Injectable } from '@angular/core'; + + +export async function sleep(ms: number): Promise { + return new Promise((resolve) => setTimeout(resolve, ms)); +} + +export type Partition = { + name: string; + data: string; + offset: number; + url: string; +}; + +export type PartitionProgress = { + index: number; + progress: number; +}; + + +export enum TestState { + Initial = "Initial", + Connected = "Connected", + LoadingFirmware = "Loading Firmware", + Flashing = "Flashing", + Flashed = "Flashed", + Restarting = "Restarting", + Restarted = "Restarted", + Testing = "Testing", + Tested = "Tested", +} + +export class LineBreakTransformer { + container: any = ""; + private controlCharacter: string; + + constructor(controlCharacter: string) { + this.container = ''; + this.controlCharacter = controlCharacter + } + + transform(chunk: any, controller: any) { + this.container += chunk; + const lines = this.container.split(this.controlCharacter); + this.container = lines.pop(); + lines.forEach((line: any) => controller.enqueue(line)); + } + + flush(controller: any) { + controller.enqueue(this.container); + } +} + +export class JsonTransformer { + + container: any = ""; + transform(chunk: any, controller: any) { + try { + controller.enqueue(JSON.parse(chunk)); + } catch(e) { + console.log("Not a valid JSON. Chunk: ", chunk, e); + } + } + + flush(controller: any) { + controller.enqueue(this.container); + } +} + +@Injectable({ + providedIn: 'root' +}) +export class UtilsService { + + constructor() { } +} diff --git a/src/assets/apps.json b/src/assets/apps.json new file mode 100644 index 0000000..ebbdf1a --- /dev/null +++ b/src/assets/apps.json @@ -0,0 +1,49 @@ +[ + { + "id": "tp-pendrive-s3-super-wifi-duck", + "name": "Super Wifi Duck", + "description": "BadUsb/ Keystroke Injection/ Wifi Duck", + "version": "1.0.0", + "repository": "https://github.com/ThingPulse/SuperWiFiDuck", + "appIcon": "/assets/apps/super-wifi-duck/super-wifi-duck.png", + "supportedDevices": [ + "tp-pendrive-s3" + ], + "partitions": [{ + "name": "Firmware", + "data": [], + "offset": 0, + "url": "./assets/apps/super-wifi-duck/app-firmware.bin" + }] + }, + { + "id": "tp-pendrive-s3-circuit-python", + "name": "Circuit Python", + "description": "Circuit Python", + "instructions": "", + "version": "1.0.0", + "repository": "https://github.com/adafruit/circuitpython", + "appIcon": "/assets/apps/circuitpython/circuitpython-logo.png", + "supportedDevices": [ + "tp-pendrive-s3" + ], + "partitions": [{ + "name": "Firmware", + "data": [], + "offset": 0, + "url": "./assets/apps/circuitpython/circuit-python-bootloader.bin" + }] + }, + { + "id": "other-device", + "name": "Weird Firmware", + "description": "Something", + "version": "1.0.0", + "repository": "https://github.com/ThingPulse/SuperWiFiDuck", + "appIcon": "/assets/apps/super-wifi-duck/super-wifi-duck.png", + "supportedDevices": [ + "tp-default-device" + ], + "partitions": [] + } +] \ No newline at end of file diff --git a/src/assets/apps/circuitpython/circuit-python-bootloader.bin b/src/assets/apps/circuitpython/circuit-python-bootloader.bin new file mode 100644 index 0000000..e628ec1 Binary files /dev/null and b/src/assets/apps/circuitpython/circuit-python-bootloader.bin differ diff --git a/src/assets/apps/circuitpython/circuit-python.uf2 b/src/assets/apps/circuitpython/circuit-python.uf2 new file mode 100644 index 0000000..2736b68 Binary files /dev/null and b/src/assets/apps/circuitpython/circuit-python.uf2 differ diff --git a/src/assets/apps/circuitpython/circuitpython-logo.png b/src/assets/apps/circuitpython/circuitpython-logo.png new file mode 100644 index 0000000..760cd07 Binary files /dev/null and b/src/assets/apps/circuitpython/circuitpython-logo.png differ diff --git a/src/assets/apps/super-wifi-duck/app-firmware.bin b/src/assets/apps/super-wifi-duck/app-firmware.bin new file mode 100644 index 0000000..f1625e3 Binary files /dev/null and b/src/assets/apps/super-wifi-duck/app-firmware.bin differ diff --git a/src/assets/apps/super-wifi-duck/super-wifi-duck.png b/src/assets/apps/super-wifi-duck/super-wifi-duck.png new file mode 100644 index 0000000..1f847fd Binary files /dev/null and b/src/assets/apps/super-wifi-duck/super-wifi-duck.png differ diff --git a/src/assets/devices.json b/src/assets/devices.json index 2174913..7c9bd74 100644 --- a/src/assets/devices.json +++ b/src/assets/devices.json @@ -1,9 +1,9 @@ [ { - "id": 1, + "id": "tp-pendrive-s3", "name": "Pendrive S3", "manufacturer": "ThingPulse", - "image-thumbnail": "assets/devices/pendrive-s3/pendrive-s3.jpg", + "imageThumbnail": "assets/devices/pendrive-s3/pendrive-s3-thumb.jpg", "productLink": "https://thingpulse.com/product/pendrive-s3/", "description": "The Pendrive S3 is a USB flash drive that can be used to store data or to run a custom firmware on a ThingPulse device." }