11import { Axios } from 'axios-observable' ;
2- import { Observable , of } from 'rxjs' ;
2+ import { AxiosError } from 'axios' ;
3+ import { Observable , of , throwError } from 'rxjs' ;
34import { catchError , map , mergeMap , switchMap , tap , toArray } from 'rxjs/operators' ;
45import { BoundsCoords } from '../model/bounds-coords.model' ;
56import { Config } from '../model/config.model' ;
@@ -17,12 +18,14 @@ import { GeoService } from './geo-service';
1718export class NetatmoService {
1819 private readonly config : Config ;
1920 private readonly boundCoords : BoundsCoords ;
20- private token ! : string ;
21- private refreshToken ! : string ;
21+ private accessToken : string ;
22+ private refreshToken : string ;
2223
2324 constructor ( config : Config ) {
2425 this . config = config ;
2526 this . boundCoords = new GeoService ( ) . getBoundsCoords ( this . config . latitude , this . config . longitude , this . config . distance ) ;
27+ this . accessToken = this . config . netatmoToken ;
28+ this . refreshToken = this . config . netatmoRefreshToken ;
2629 }
2730
2831 /**
@@ -125,44 +128,69 @@ export class NetatmoService {
125128 }
126129
127130 /**
128- * Find public access token in weather map home page
131+ * Check if we have a valid token
129132 * @returns
130133 */
131134 private auth ( ) : Observable < null > {
132- if ( this . token ) {
135+ if ( this . accessToken ) {
133136 return of ( null ) ;
134137 }
135138
136- return Axios . get ( `https://weathermap.netatmo.com` )
137- . pipe (
138- tap ( ( resp ) => {
139- if ( this . config . verbose ) {
140- console . log ( `Netatmo auth response` , resp ) ;
141- }
142- } ) ,
143- map ( ( resp ) => {
144- const matches = / a c c e s s T o k e n : " ? ( .* ) " / gm. exec ( resp . data ) ;
145- if ( matches && matches . length > 1 ) {
146- this . token = matches [ 1 ] . trim ( ) ;
147- }
148- return null ;
149- } )
150- ) ;
139+ // No token available, try to refresh
140+ return this . doRefreshToken ( ) ;
141+ }
142+
143+ /**
144+ * Refresh the access token using the refresh token
145+ * @see https://dev.netatmo.com/apidocumentation/oauth
146+ * @returns
147+ */
148+ private doRefreshToken ( ) : Observable < null > {
149+ if ( ! this . refreshToken || ! this . config . netatmoClientId || ! this . config . netatmoClientSecret ) {
150+ return throwError ( ( ) => new Error ( 'Missing refresh token or client credentials for token refresh' ) ) ;
151+ }
152+
153+ if ( this . config . verbose ) {
154+ console . log ( 'Refreshing Netatmo access token...' ) ;
155+ }
156+
157+ const params = new URLSearchParams ( ) ;
158+ params . append ( 'grant_type' , 'refresh_token' ) ;
159+ params . append ( 'refresh_token' , this . refreshToken ) ;
160+ params . append ( 'client_id' , this . config . netatmoClientId ) ;
161+ params . append ( 'client_secret' , this . config . netatmoClientSecret ) ;
162+
163+ return Axios . post ( 'https://api.netatmo.com/oauth2/token' , params . toString ( ) , {
164+ headers : {
165+ 'Content-Type' : 'application/x-www-form-urlencoded'
166+ }
167+ } ) . pipe (
168+ tap ( ( resp ) => {
169+ if ( this . config . verbose ) {
170+ console . log ( 'Token refresh successful' ) ;
171+ }
172+ this . accessToken = resp . data . access_token ;
173+ this . refreshToken = resp . data . refresh_token ;
174+ } ) ,
175+ map ( ( ) => null ) ,
176+ catchError ( ( err ) => {
177+ console . error ( 'Failed to refresh token' , err . response ?. data || err . message ) ;
178+ return throwError ( ( ) => new Error ( 'Failed to refresh Netatmo token' ) ) ;
179+ } )
180+ ) ;
151181 }
152182
153183 /**
154184 * Load datas from Netatmo
185+ * @param isRetry - indicates if this is a retry after token refresh
155186 */
156- private getData ( ) : Observable < WeatherStation [ ] > {
187+ private getData ( isRetry = false ) : Observable < WeatherStation [ ] > {
157188 if ( this . config . verbose ) {
158189 console . log ( 'Bounding coordinates to search nearby' , this . boundCoords ) ;
159190 }
160191
161192 return Axios . get ( `https://app.netatmo.net/api/getpublicmeasures` , {
162- headers : {
163- "Authorization" : `Bearer ${ this . token } ` ,
164- } ,
165- data : {
193+ params : {
166194 'lat_ne' : this . boundCoords . ne . lat ,
167195 'lon_ne' : this . boundCoords . ne . lng ,
168196 'lat_sw' : this . boundCoords . sw . lat ,
@@ -172,6 +200,7 @@ export class NetatmoService {
172200 'zoom' : 10 ,
173201 'date_end' : 'last' ,
174202 'quality' : 7 ,
203+ 'access_token' : this . accessToken ,
175204 }
176205 } )
177206 . pipe (
@@ -180,7 +209,19 @@ export class NetatmoService {
180209 console . log ( `Weather API response` , resp . data ) ;
181210 }
182211 } ) ,
183- map ( ( resp ) => resp . data . body )
212+ map ( ( resp ) => resp . data . body ) ,
213+ catchError ( ( err : AxiosError ) => {
214+ // If 403 and not already a retry, refresh token and retry once
215+ if ( err . response ?. status === 403 && ! isRetry ) {
216+ if ( this . config . verbose ) {
217+ console . log ( 'Received 403, attempting to refresh token and retry...' ) ;
218+ }
219+ return this . doRefreshToken ( ) . pipe (
220+ switchMap ( ( ) => this . getData ( true ) )
221+ ) ;
222+ }
223+ return throwError ( ( ) => err ) ;
224+ } )
184225 ) ;
185226 }
186227
0 commit comments