Skip to content

Commit

Permalink
Added auto fetching of appVersion from AppBrain website
Browse files Browse the repository at this point in the history
  • Loading branch information
seime committed Jul 7, 2024
1 parent f86c6c9 commit 49fedfa
Show file tree
Hide file tree
Showing 7 changed files with 1,823 additions and 98 deletions.
13 changes: 5 additions & 8 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -27,14 +27,11 @@ See full example below for how to configure using thing files.

* `username` = Same as you use in the mobile app (_mandatory_)
* `password` = Same as you use in the mobile app (_mandatory_)
* `appVersion` = The version of your Panasonic Comfort Cloud mobile app. You can find this information in the
application section of your phone (_mandatory_ with default value). Current default appVersion is `1.20.0` as of May
3rd 2024.
* `refreshInterval` = Number of seconds between refresh calls to the server (_optional_)

NOTE: If your account refuses to go online with error message `New app version published - check the version number of
your mobile app and enter the value as account config parameter (currently using <current version>)`, update
the `appVersion` config field. This _may_ work if the API has not changed too much.
*Advanced configuration:*

* `appVersion` = Override the automatically fetched latest (mobile) app version. Only use if automatic failure occurs.
* `refreshInterval` = Number of seconds between refresh calls to the server (_optional_)

### aircondition

Expand Down Expand Up @@ -105,7 +102,7 @@ Some channels are still missing like iAutoX and ecoNavi. If you have a device th
panasoniccomfortcloud.things:

```
Bridge panasoniccomfortcloud:account:accountName "Panasonic Comfort Cloud account" [ username="[email protected]", password="XXXXXXX", refreshInterval="120", appVersion="Defaults to 1.17.0, check current mobile app version and bump to this if trouble" ] {
Bridge panasoniccomfortcloud:account:accountName "Panasonic Comfort Cloud account" [ username="[email protected]", password="XXXXXXX", refreshInterval="120", appVersion="1.21.0" // Optional ] {
Thing aircondition bedroom1 "AC Bedroom" [ deviceId="CS-TZ25WKEW+XXXXXXXX" ]
}
```
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -50,12 +50,16 @@
* @author Arne Seime - Initial contribution
*/
public class ApiBridge {
public static final String APPLICATION_JSON_CHARSET_UTF_8 = "application/json; charset=utf-8";
public static final MediaType JSON = MediaType.parse(APPLICATION_JSON_CHARSET_UTF_8);

private static final String APP_CLIENT_ID = "Xmy6xIYIitMxngjB2rHvlm6HSDNnaMJx";
private static final String AUTH_0_CLIENT = "eyJuYW1lIjoiQXV0aDAuQW5kcm9pZCIsImVudiI6eyJhbmRyb2lkIjoiMzAifSwidmVyc2lvbiI6IjIuOS4zIn0=";
private static final String REDIRECT_URI = "panasonic-iot-cfc://authglb.digital.panasonic.com/android/com.panasonic.ACCsmart/callback";
private static final String BASE_PATH_AUTH = "https://authglb.digital.panasonic.com";
private static final String BASE_PATH_ACC = "https://accsmart.panasonic.com";
private static final String APPBRAIN_URL = "https://www.appbrain.com/app/panasonic-comfort-cloud/com.panasonic.ACCsmart";
private static final String DEFAULT_APP_VERSION = "1.21.0";

private static final String ACCESS_TOKEN_KEY = "accessToken";
private static final String REFRESH_TOKEN_KEY = "refreshToken";
Expand All @@ -67,20 +71,14 @@ public class ApiBridge {

private final Logger logger = LoggerFactory.getLogger(ApiBridge.class);

private String clientId;
private String username;
private String password;

private String appVersion;

private Gson gson;

private OkHttpClient client;

private Storage<String> storage;

public static final String APPLICATION_JSON_CHARSET_UTF_8 = "application/json; charset=utf-8";
public static final MediaType JSON = MediaType.parse(APPLICATION_JSON_CHARSET_UTF_8);

public ApiBridge(Storage<String> storage) {
this.storage = storage;
HttpLoggingInterceptor logging = new HttpLoggingInterceptor(message -> logger.debug(message));
Expand Down Expand Up @@ -109,13 +107,58 @@ public List<Cookie> loadForRequest(HttpUrl httpUrl) {
gson = new GsonBuilder().setLenient().setPrettyPrinting().create();
}

public void init(String username, String password, String appVersion) {
private static @NonNull Map<String, String> parseCookies(Response redirectResponse) {
Map<String, String> cookies = new HashMap<>();

for (String header : redirectResponse.headers("Set-Cookie")) {
Cookie cookie = Cookie.parse(HttpUrl.parse("https://www.example.com/"), header);
cookies.put(cookie.name(), cookie.value());

}
return cookies;
}

public static String generateRandomStringHex(int bitLength) {
StringBuilder b = new StringBuilder();
for (int i = 0; i < bitLength; i++) {
b.append(Integer.toHexString((int) (Math.random() * 16)));
}

return b.toString();
}

public static String generateHash(String codeVerifier) throws NoSuchAlgorithmException {
MessageDigest digest = MessageDigest.getInstance("SHA-256");
byte[] encodedhash = digest.digest(codeVerifier.getBytes(StandardCharsets.UTF_8));
return Base64.getUrlEncoder().encodeToString(encodedhash).replace("=", "");
}

public static String generateRandomString(int length) {
StringBuilder b = new StringBuilder();
for (int i = 0; i < length; i++) {
b.append((char) (Math.random() * 26 + 'a'));
}

return b.toString();
}

public void init(String username, String password, String configuredAppVersion) {
this.username = username;
this.password = password;
this.appVersion = appVersion;
}

String clientId;
if (configuredAppVersion == null) {
logger.debug("No configured appVersion in thing configuration, trying to fetch from AppBrain website");
appVersion = getAppVersion();
if (appVersion == null) {
logger.info(
"Could not fetch appVersion dynamically, and no value is provided on the bridge thing. Defaulting to {}",
DEFAULT_APP_VERSION);
appVersion = DEFAULT_APP_VERSION;
}
} else {
appVersion = configuredAppVersion;
}
}

private Request buildRequest(Token token, final AbstractRequest req) {

Expand Down Expand Up @@ -167,7 +210,7 @@ public <T> T sendRequest(final AbstractRequest req, final Type responseType) thr
return token;
} catch (Exception e) {
clearToken();
throw new CommunicationException("Error obtaining access token - check credentials", e);
throw new CommunicationException("Error obtaining access token - check credentials and appVersion", e);
}
}

Expand Down Expand Up @@ -339,8 +382,19 @@ private Token doV2AuthorizationFlow() throws IOException, NoSuchAlgorithmExcepti
Response getAccClientResponse = client.newCall(getAccClientIdRequest).execute();

if (getAccClientResponse.code() != 200) {
throw new CommunicationException("Get clientId request failed with code " + getAccClientResponse.code()
+ ". Check credentials and appVersion");

final JsonObject o = JsonParser.parseString(getAccClientResponse.body().string()).getAsJsonObject();
int errorCode = o.has("code") ? o.get("code").getAsInt() : -1;
String errorMessage = o.has("message") ? o.get("message").getAsString() : "<not provided>";

if (errorCode == ERROR_CODE_UPDATE_VERSION) {
throw new CommunicationException(String.format(
"New app version published - check the version number of your mobile app and enter the value as account config parameter (currently using %s)",
appVersion));
} else {
throw new CommunicationException("Get clientId request failed with code " + getAccClientResponse.code()
+ " and message " + errorMessage + ". Check credentials and appVersion");
}
}

String bodyString = getAccClientResponse.body().string();
Expand Down Expand Up @@ -383,39 +437,29 @@ private Token refreshToken(Token currentToken) throws IOException, Communication
return refreshedToken;
}

private static @NonNull Map<String, String> parseCookies(Response redirectResponse) {
Map<String, String> cookies = new HashMap<>();

for (String header : redirectResponse.headers("Set-Cookie")) {
Cookie cookie = Cookie.parse(HttpUrl.parse("https://www.example.com/"), header);
cookies.put(cookie.name(), cookie.value());
private String getAppVersion() {
try {
Request req = new Request.Builder().url(APPBRAIN_URL).get().build();
Response rsp = client.newCall(req).execute();

}
return cookies;
}
String body = rsp.body().string();
return parseAppBrainAppVersion(body);

public static String generateRandomStringHex(int bitLength) {
StringBuilder b = new StringBuilder();
for (int i = 0; i < bitLength; i++) {
b.append(Integer.toHexString((int) (Math.random() * 16)));
} catch (Exception e) {
logger.warn("Exception getting appVersion", e);
}

return b.toString();
return null;
}

public static String generateHash(String codeVerifier) throws NoSuchAlgorithmException {
MessageDigest digest = MessageDigest.getInstance("SHA-256");
byte[] encodedhash = digest.digest(codeVerifier.getBytes(StandardCharsets.UTF_8));
return Base64.getUrlEncoder().encodeToString(encodedhash).replace("=", "");
}
public String parseAppBrainAppVersion(String body) {
Document doc = Jsoup.parse(body);
Elements elements = doc.selectXpath("//meta[@itemprop='softwareVersion']");

public static String generateRandomString(int length) {
StringBuilder b = new StringBuilder();
for (int i = 0; i < length; i++) {
b.append((char) (Math.random() * 26 + 'a'));
if (elements.size() == 1) {
return elements.get(0).attr("content");
}

return b.toString();
return null;
}

public <T> T sendRequestInternal(final Request request, final AbstractRequest req, final Type responseType)
Expand Down Expand Up @@ -458,6 +502,42 @@ public <T> T sendRequestInternal(final Request request, final AbstractRequest re
}
}

@Nullable
private Token getStoredToken() {
String accessToken = storage.get(ACCESS_TOKEN_KEY);
String refreshToken = storage.get(REFRESH_TOKEN_KEY);
String clientId = storage.get(CLIENT_ID_KEY);
String tokenExpiryString = storage.get(TOKEN_EXPIRY_KEY);
if (tokenExpiryString == null) {
tokenExpiryString = Instant.now().minus(1, ChronoUnit.MINUTES).getEpochSecond() + ""; // Expired, but should
// not happen
}
long tokenExpiry = Long.parseLong(tokenExpiryString);
String scope = storage.get(SCOPE_KEY);

if (accessToken == null || refreshToken == null || clientId == null || scope == null) {
return null;
}

return new Token(accessToken, refreshToken, clientId, tokenExpiry, scope);
}

private void storeToken(Token token) {
storage.put(ACCESS_TOKEN_KEY, token.getAccessToken());
storage.put(REFRESH_TOKEN_KEY, token.getRefreshToken());
storage.put(CLIENT_ID_KEY, token.getClientId());
storage.put(TOKEN_EXPIRY_KEY, String.valueOf(token.getTokenExpiry()));
storage.put(SCOPE_KEY, token.getScope());
}

private void clearToken() {
storage.remove(ACCESS_TOKEN_KEY);
storage.remove(REFRESH_TOKEN_KEY);
storage.remove(CLIENT_ID_KEY);
storage.remove(TOKEN_EXPIRY_KEY);
storage.remove(SCOPE_KEY);
}

private static class Token {
String accessToken;
String refreshToken;
Expand Down Expand Up @@ -501,40 +581,4 @@ public boolean isExpired() {
return tokenExpiry < Instant.now().getEpochSecond();
}
}

@Nullable
private Token getStoredToken() {
String accessToken = storage.get(ACCESS_TOKEN_KEY);
String refreshToken = storage.get(REFRESH_TOKEN_KEY);
String clientId = storage.get(CLIENT_ID_KEY);
String tokenExpiryString = storage.get(TOKEN_EXPIRY_KEY);
if (tokenExpiryString == null) {
tokenExpiryString = Instant.now().minus(1, ChronoUnit.MINUTES).getEpochSecond() + ""; // Expired, but should
// not happen
}
long tokenExpiry = Long.parseLong(tokenExpiryString);
String scope = storage.get(SCOPE_KEY);

if (accessToken == null || refreshToken == null || clientId == null || scope == null) {
return null;
}

return new Token(accessToken, refreshToken, clientId, tokenExpiry, scope);
}

private void storeToken(Token token) {
storage.put(ACCESS_TOKEN_KEY, token.getAccessToken());
storage.put(REFRESH_TOKEN_KEY, token.getRefreshToken());
storage.put(CLIENT_ID_KEY, token.getClientId());
storage.put(TOKEN_EXPIRY_KEY, String.valueOf(token.getTokenExpiry()));
storage.put(SCOPE_KEY, token.getScope());
}

private void clearToken() {
storage.remove(ACCESS_TOKEN_KEY);
storage.remove(REFRESH_TOKEN_KEY);
storage.remove(CLIENT_ID_KEY);
storage.remove(TOKEN_EXPIRY_KEY);
storage.remove(SCOPE_KEY);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -12,25 +12,18 @@
*/
package no.seime.openhab.binding.panasoniccomfortcloud.internal.config;

import org.eclipse.jdt.annotation.NonNullByDefault;
import org.eclipse.jdt.annotation.Nullable;
import org.openhab.core.config.core.Configuration;

/**
* The {@link AccountConfiguration} class contains fields mapping thing configuration parameters.
*
* @author Arne Seime - Initial contribution
*/
@NonNullByDefault
public class AccountConfiguration extends Configuration {

@Nullable
public String username;
@Nullable
public String password;

public String appVersion = "1.21.0";

public String appVersion = null;
public int refreshInterval = 120;

@Override
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@
import java.util.concurrent.ScheduledFuture;
import java.util.concurrent.TimeUnit;

import org.apache.commons.lang3.StringUtils;
import org.eclipse.jdt.annotation.NonNullByDefault;
import org.openhab.core.storage.StorageService;
import org.openhab.core.thing.Bridge;
Expand Down Expand Up @@ -81,7 +82,7 @@ public void initialize() {
updateStatus(ThingStatus.UNKNOWN);
AccountConfiguration loadedConfig = getConfigAs(AccountConfiguration.class);
config = loadedConfig;
apiBridge.init(loadedConfig.username, loadedConfig.password, loadedConfig.appVersion);
apiBridge.init(loadedConfig.username, loadedConfig.password, StringUtils.trimToNull(loadedConfig.appVersion));
int refreshInterval = config.refreshInterval;
if (refreshInterval < MIN_TIME_BETWEEEN_MODEL_UPDATES) {
logger.warn("Refresh interval too short, setting minimum value of {}", MIN_TIME_BETWEEEN_MODEL_UPDATES);
Expand Down
6 changes: 3 additions & 3 deletions src/main/resources/OH-INF/config/config.xml
Original file line number Diff line number Diff line change
Expand Up @@ -17,10 +17,10 @@ https://openhab.org/schemas/config-description-1.0.0.xsd">
</parameter>
<parameter name="appVersion" type="text" required="false">
<label>App version</label>
<description>Your Panasonic Comfort Cloud app version code, ie '1.21.0'. You can find this information in the apps
section of your mobile phone. If no value provided, it will use the current binding default.
<description>Your Panasonic Comfort Cloud app version code. Only fill in if the binding is unable to fetch the latest
version automatically - OR if you want to simulate a different app version.
</description>
<default>1.21.0</default>
<advanced>true</advanced>
</parameter>

<parameter name="refreshInterval" type="integer" min="30" unit="s">
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@

import static org.junit.jupiter.api.Assertions.assertEquals;

import java.io.IOException;
import java.io.InputStream;
import java.security.NoSuchAlgorithmException;

import org.junit.jupiter.api.Test;
Expand All @@ -25,10 +27,10 @@ public class APIClientTest {
public void test() throws PanasonicComfortCloudException {
ApiBridge apiBridge = new ApiBridge(storage);

String username = "FILL IN USERNAME TO TEST";
String password = "FILL IN PASSWORD TO TEST";
String username = "EMAIL USERNAME";
String password = "PASSWORD";

apiBridge.init(username, password, "1.20.0");
apiBridge.init(username, password, null);
apiBridge.sendRequest(new GetGroupsRequest(), new TypeToken<GetGroupsResponse>() {
}.getType());
}
Expand All @@ -52,4 +54,14 @@ public void testGenerateRandomHexString() {
String random = ApiBridge.generateRandomStringHex(length);
assertEquals(length, random.length());
}

@Test
public void testParseAppBrain() throws IOException {
InputStream is = getClass().getResourceAsStream("/appbrain_index.html");
String html = new String(is.readAllBytes());

ApiBridge apiBridge = new ApiBridge(storage);
String appVersion = apiBridge.parseAppBrainAppVersion(html);
assertEquals("1.21.0", appVersion);
}
}
Loading

0 comments on commit 49fedfa

Please sign in to comment.