generate binary data of map picture

Signed-off-by: Peter Siegmund <peter@rdkr.com>
This commit is contained in:
Peter Siegmund
2024-06-13 13:12:31 +02:00
parent 171f49cb34
commit 8d76f1d612
24 changed files with 424 additions and 62 deletions

View File

@@ -37,6 +37,12 @@
<artifactId>spring-boot-starter-cache</artifactId>
</dependency>
<dependency>
<groupId>org.bouncycastle</groupId>
<artifactId>bcpkix-jdk18on</artifactId>
<version>1.78.1</version>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-devtools</artifactId>

View File

@@ -1,13 +1,17 @@
package com.rdkr.tide_display.backend;
import java.security.Security;
import org.bouncycastle.jce.provider.BouncyCastleProvider;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
@SpringBootApplication
public class Application {
public static void main(String[] args) {
SpringApplication.run(Application.class, args);
}
public static void main(String[] args) {
Security.addProvider(new BouncyCastleProvider());
SpringApplication.run(Application.class, args);
}
}

View File

@@ -0,0 +1,9 @@
package com.rdkr.tide_display.backend.apple;
import java.net.URI;
public interface Snapshot {
URI generateSnapshotURI(double latitude, double longitude);
}

View File

@@ -0,0 +1,60 @@
package com.rdkr.tide_display.backend.apple.snapshot;
import com.rdkr.tide_display.backend.apple.Snapshot;
import com.rdkr.tide_display.backend.crypto.Platforms;
import java.net.URI;
import java.nio.charset.StandardCharsets;
import java.security.Signature;
import java.util.Base64;
import lombok.val;
import org.springframework.beans.factory.annotation.Qualifier;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Component;
import org.springframework.web.util.UriComponentsBuilder;
@Component()
public class SnapshotImpl implements Snapshot {
private final Platforms platforms;
@Value("${APPLE_TEAM_ID:dummy}")
private String teamId;
@Value("${APPLE_KEY_ID:dummy}")
private String keyId;
public SnapshotImpl(@Qualifier("AppleCrypto") Platforms platforms) {
this.platforms = platforms;
}
public URI generateSnapshotURI(double latitude, double longitude) {
val uriBuilder = UriComponentsBuilder.fromUriString("https://snapshot.apple-mapkit.com/api/v1/snapshot")
.queryParam("center", latitude + "," + longitude)
.queryParam("t", "standard")
.queryParam("scale", "1")
.queryParam("size", "540x540")
.queryParam("lang", "de-DE")
.queryParam("poi", "0")
.queryParam("teamId", teamId)
.queryParam("keyId", keyId);
return signRequest(uriBuilder);
}
private URI signRequest(UriComponentsBuilder uriBuilder) {
try {
val pk = platforms.loadPrivateKey("MapKit_D28AJ2A3UT.p8");
val signature = Signature.getInstance("SHA256withECDSA", "BC");
signature.initSign(pk);
signature.update(uriBuilder.toString().getBytes());
val signatureBytes = new String(signature.sign(), StandardCharsets.UTF_8);
val sign = Base64.getEncoder().encodeToString(signatureBytes.getBytes());
return uriBuilder
.queryParam("signature", sign)
.build()
.toUri();
} catch (Exception e) {
throw new RuntimeException(e);
}
}
}

View File

@@ -0,0 +1,17 @@
package com.rdkr.tide_display.backend.common.config;
import com.rdkr.tide_display.backend.common.interceptor.RequestResponseLoggingInterceptor;
import org.springframework.context.annotation.Bean;
import org.springframework.stereotype.Component;
import org.springframework.web.client.RestClient;
@Component
public class AppConfig {
@Bean
public RestClient.Builder restClient() {
return RestClient
.builder()
.requestInterceptor(new RequestResponseLoggingInterceptor());
}
}

View File

@@ -1,4 +1,4 @@
package com.rdkr.tide_display.backend.common;
package com.rdkr.tide_display.backend.common.config;
import org.springframework.cache.CacheManager;
import org.springframework.cache.annotation.EnableCaching;
@@ -12,6 +12,11 @@ public class CacheConfig {
@Bean
public CacheManager cacheManager() {
return new ConcurrentMapCacheManager("map", "grayMap", "ePaper", "printHexValues");
return new ConcurrentMapCacheManager(
"privateKey",
"map",
"grayMap",
"ePaper",
"printHexValues");
}
}

View File

@@ -0,0 +1,54 @@
package com.rdkr.tide_display.backend.common.interceptor;
import java.nio.charset.StandardCharsets;
import lombok.extern.slf4j.Slf4j;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.http.HttpRequest;
import org.springframework.http.client.ClientHttpRequestExecution;
import org.springframework.http.client.ClientHttpRequestInterceptor;
import org.springframework.http.client.ClientHttpResponse;
import org.springframework.util.StreamUtils;
import java.io.IOException;
import java.nio.charset.Charset;
@Slf4j
public class RequestResponseLoggingInterceptor implements ClientHttpRequestInterceptor {
@Override
public ClientHttpResponse intercept(HttpRequest request, byte[] body, ClientHttpRequestExecution execution) throws IOException
{
logRequest(request, body);
ClientHttpResponse response = execution.execute(request, body);
logResponse(response);
return response;
}
private void logRequest(HttpRequest request, byte[] body) throws IOException
{
if (log.isDebugEnabled())
{
log.debug("=========================== request begin ================================================");
log.debug("URI : {}", request.getURI());
log.debug("Method : {}", request.getMethod());
log.debug("Headers : {}", request.getHeaders());
//log.debug("Request body: {}", new String(body, StandardCharsets.UTF_8));
log.debug("=========================== request end ==================================================");
}
}
private void logResponse(ClientHttpResponse response) throws IOException
{
if (log.isDebugEnabled())
{
log.debug("=========================== response begin ===============================================");
log.debug("Status code : {}", response.getStatusCode());
log.debug("Status text : {}", response.getStatusText());
log.debug("Headers : {}", response.getHeaders());
//log.debug("Response body: {}", StreamUtils.copyToString(response.getBody(), StandardCharsets.UTF_8));
log.debug("=========================== response end =================================================");
}
}
}

View File

@@ -0,0 +1,9 @@
package com.rdkr.tide_display.backend.crypto;
import java.security.PrivateKey;
public interface Platforms {
PrivateKey loadPrivateKey(String filename);
}

View File

@@ -0,0 +1,31 @@
package com.rdkr.tide_display.backend.crypto.platforms;
import com.rdkr.tide_display.backend.crypto.Platforms;
import java.io.FileReader;
import java.security.PrivateKey;
import lombok.val;
import org.bouncycastle.asn1.pkcs.PrivateKeyInfo;
import org.bouncycastle.openssl.PEMParser;
import org.bouncycastle.openssl.jcajce.JcaPEMKeyConverter;
import org.springframework.cache.annotation.Cacheable;
import org.springframework.stereotype.Component;
import org.springframework.util.ResourceUtils;
@Component("AppleCrypto")
public class AppleCrypto implements Platforms {
@Cacheable(value = "privateKey", key = "#filename")
public PrivateKey loadPrivateKey(String filename) {
try {
val file = ResourceUtils.getFile("classpath:apple/" + filename );
val pemParser = new PEMParser(new FileReader(file));
val converter = new JcaPEMKeyConverter();
val object = (PrivateKeyInfo) pemParser.readObject();
val pKey = converter.getPrivateKey(object);
pemParser.close();
return pKey;
} catch (Exception e) {
throw new RuntimeException("Failed to load private key", e);
}
}
}

View File

@@ -0,0 +1,15 @@
package com.rdkr.tide_display.backend.crypto.platforms;
import com.rdkr.tide_display.backend.crypto.Platforms;
import java.security.PrivateKey;
import org.springframework.cache.annotation.Cacheable;
import org.springframework.stereotype.Component;
@Component("GoogleCrypto")
public class GoogleCrypto implements Platforms {
@Cacheable(value = "privateKey", key = "#filename")
public PrivateKey loadPrivateKey(String filename) {
return null;
}
}

View File

@@ -8,8 +8,8 @@ import org.springframework.web.bind.annotation.RestController;
@RequestMapping("/")
public class RootController {
@GetMapping()
public String index() {
return "Tide Display Backend";
}
@GetMapping()
public String index() {
return "Tide Display Backend";
}
}

View File

@@ -41,5 +41,5 @@ public class Firmware {
private String espIdf;
private String compiled;
}
}
}

View File

@@ -5,9 +5,12 @@ import java.util.List;
import org.springframework.web.multipart.MultipartFile;
public interface StorageService {
List<Firmware> getFirmwareVersions();
byte[] download(String version, String filename);
String upload(MultipartFile file) throws IOException;
void save(String filename, byte[] data, String contentType, int expire);
}

View File

@@ -9,7 +9,7 @@ import org.springframework.stereotype.Component;
public class GoogleCloudBean {
@Bean
public Storage getStorage(){
public Storage getStorage() {
return StorageOptions.getDefaultInstance().getService();
}
}

View File

@@ -8,9 +8,11 @@ import com.rdkr.tide_display.backend.gcp.StorageService;
import jakarta.annotation.Nullable;
import java.io.IOException;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import lombok.RequiredArgsConstructor;
import lombok.val;
import org.springframework.http.MediaType;
import org.springframework.stereotype.Service;
import org.springframework.web.multipart.MultipartFile;
@@ -34,7 +36,8 @@ public class StorageServiceImpl implements StorageService {
val result = new ArrayList<Firmware>();
val bucket = storage.list(bucketName, BlobListOption.currentDirectory(), BlobListOption.prefix("firmware/"));
for (val directory : bucket.iterateAll()) {
val files = storage.list(bucketName, BlobListOption.currentDirectory(), BlobListOption.prefix(directory.getName()));
val files = storage.list(bucketName, BlobListOption.currentDirectory(),
BlobListOption.prefix(directory.getName()));
for (val file : files.iterateAll()) {
val name = file.getName();
if (name.endsWith(".bin")) {
@@ -53,7 +56,8 @@ public class StorageServiceImpl implements StorageService {
val meta = new Firmware.FirmwareMeta(projectName, flashSize, espIdf, compiled);
result.add(
new Firmware(major, minor, patch, "/files/" + major + "." + minor + "." + patch + "/firmware.bin", file.getEtag(), file.getUpdateTimeOffsetDateTime(), meta));
new Firmware(major, minor, patch, "/files/" + major + "." + minor + "." + patch + "/firmware.bin",
file.getEtag(), file.getUpdateTimeOffsetDateTime(), meta));
}
}
}
@@ -84,7 +88,19 @@ public class StorageServiceImpl implements StorageService {
return null;
}
val version = extract(bytes, 48, 30);
storage.create(BlobInfo.newBuilder(bucketName, "firmware/" + version + "/firmware.bin").build(), bytes);
save("firmware/" + version + "/firmware.bin", bytes, MediaType.APPLICATION_OCTET_STREAM_VALUE, 0);
return version;
}
@Override
public void save(String filename, byte[] data, String contentType, int expire) {
val metadata = new HashMap<String, String>();
metadata.put("Cache-Control", "public, max-age=" + expire);
val blobInfo = BlobInfo
.newBuilder(bucketName, filename)
.setContentType(contentType)
.setMetadata(metadata)
.build();
storage.create(blobInfo, data);
}
}

View File

@@ -4,6 +4,7 @@ import com.rdkr.tide_display.backend.images.domain.ImageFormat;
import com.rdkr.tide_display.backend.images.services.ImageConverterService;
import com.rdkr.tide_display.backend.images.services.MapService;
import java.io.IOException;
import java.util.List;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import lombok.val;
@@ -25,13 +26,14 @@ public class MapController {
@GetMapping
public ResponseEntity<?> getMap(
@RequestParam(required = false, defaultValue = "GoogleMaps") String mapProvider,
@RequestParam(required = false, defaultValue = "53.541962") double latitude,
@RequestParam(required = false, defaultValue = "9.993402") double longitude,
@RequestParam(required = false, defaultValue = "15") int zoom,
@RequestParam(required = false, defaultValue = "BINARY") ImageFormat format)
throws IOException {
val map = mapService.getStaticMap(latitude, longitude, zoom);
var result = converterService.grayscale(map);
val map = mapService.getStaticMap(mapProvider, latitude, longitude, zoom);
var result = converterService.convert(map);
val response = ResponseEntity.ok();
if (ImageFormat.PNG.equals(format)) {
return response.contentType(MediaType.IMAGE_PNG).body(result);
@@ -40,4 +42,9 @@ public class MapController {
return response.contentType(MediaType.APPLICATION_JSON).body(body);
}
}
@GetMapping("/providers")
public ResponseEntity<List<String>> getMapProviders() {
return ResponseEntity.ok(mapService.getMapProviders());
}
}

View File

@@ -16,41 +16,35 @@ import org.springframework.stereotype.Service;
@Slf4j
public class ImageConverterService {
public byte[] grayscale(byte[] image) throws IOException {
return grayscale_manual(image);
}
public byte[] grayscale_automatic(byte[] image) throws IOException {
public byte[] convert(byte[] image) throws IOException {
val img = ImageIO.read(new ByteArrayInputStream(image));
val grayImage = new BufferedImage(img.getWidth(), img.getHeight(), BufferedImage.TYPE_BYTE_GRAY);
val g = grayImage.getGraphics();
g.drawImage(img, 0, 0, null);
g.dispose();
val out = new ByteArrayOutputStream();
ImageIO.write(grayImage, "png", out);
return out.toByteArray();
}
val celShadedImage = new BufferedImage(img.getWidth(), img.getHeight(), BufferedImage.TYPE_INT_RGB);
public byte[] grayscale_manual(byte[] image) throws IOException {
val img = ImageIO.read(new ByteArrayInputStream(image));
val grayImage = new BufferedImage(img.getWidth(), img.getHeight(), BufferedImage.TYPE_BYTE_GRAY);
int rgb = 0, r = 0, g = 0, b = 0;
for (int y = 0; y < img.getHeight(); y++) {
for (int x = 0; x < img.getWidth(); x++) {
rgb = img.getRGB(x, y);
r = (rgb >> 16) & 0xFF;
g = (rgb >> 8) & 0xFF;
b = (rgb & 0xFF);
rgb = (int) (r * 0.299 + g * 0.587 + b * 0.114);
rgb = (255 << 24) | (rgb << 16) | (rgb << 8) | rgb;
grayImage.setRGB(x, y, rgb);
// Pixel-Farbe auslesen
int rgb = img.getRGB(x, y);
int r = (rgb >> 16) & 0xFF;
int g = (rgb >> 8) & 0xFF;
int b = rgb & 0xFF;
int gray = (r + g + b) / 3;
int celShadeLevel = gray / 16;
int newGray = celShadeLevel * 16;
int newRGB = (newGray << 16) | (newGray << 8) | newGray;
celShadedImage.setRGB(x, y, newRGB);
}
}
val out = new ByteArrayOutputStream();
ImageIO.write(grayImage, "png", out);
ImageIO.write(celShadedImage, "png", out);
return out.toByteArray();
}
public ImageResponse ePaperFormat(byte[] image) throws IOException {
val img = ImageIO.read(new ByteArrayInputStream(image));
val width = img.getWidth();
@@ -74,11 +68,11 @@ public class ImageConverterService {
result.write(b);
}
}
return new ImageResponse(width, height, Base64.getEncoder().encode(result.toByteArray()));
}
public void printHexValues(byte[] bytes) throws IOException {
val hex = HexFormat.of().withUpperCase().withPrefix("0x").withSuffix(", ").formatHex(bytes);
log.info("Hex value: {}", hex);
val bytes = result.toByteArray();
if (log.isDebugEnabled()) {
val hex = HexFormat.of().withUpperCase().withPrefix("0x").withSuffix(", ").formatHex(bytes);
log.debug("Image: {}", hex);
}
return new ImageResponse(width, height, Base64.getEncoder().encode(bytes));
}
}

View File

@@ -1,7 +1,11 @@
package com.rdkr.tide_display.backend.images.services;
import java.util.List;
public interface MapService {
byte[] getStaticMap(double latitude, double longitude, int zoom);
List<String> getMapProviders();
byte[] getStaticMap(String provider, double latitude, double longitude, int zoom);
}

View File

@@ -1,25 +1,47 @@
package com.rdkr.tide_display.backend.images.services;
import com.rdkr.tide_display.backend.gcp.StorageService;
import com.rdkr.tide_display.backend.images.services.mapProvider.MapProvider;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import lombok.RequiredArgsConstructor;
import lombok.val;
import org.springframework.cache.annotation.Cacheable;
import org.springframework.http.HttpHeaders;
import org.springframework.http.MediaType;
import org.springframework.stereotype.Component;
import org.springframework.stereotype.Service;
import org.springframework.web.client.RestClient;
import org.springframework.util.MimeType;
@Service
public class MapServiceImpl implements MapService {
@Cacheable("map")
public byte[] getStaticMap(double latitude, double longitude, int zoom) {
val client = RestClient.create("https://maps.googleapis.com/maps/api/staticmap");
return client.get()
.uri(uriBuilder -> uriBuilder
.queryParam("center", latitude + "," + longitude)
.queryParam("zoom", "" + zoom)
.queryParam("size", "540x540")
.queryParam("map_id", "2f371c2346218fe8")
.queryParam("key", "AIzaSyARgP_FKFXsrcgVd_HVWoIfH5N8-a88wlQ")
.build())
.retrieve()
.body(byte[].class);
private final Map<String, MapProvider> providers = new HashMap<>();
private final StorageService storageService;
public MapServiceImpl(List<MapProvider> mapProviders, StorageService storageService) {
this.storageService = storageService;
mapProviders.forEach(
provider -> providers.put(provider.getClass().getAnnotation(Component.class).value().toUpperCase(), provider));
}
public List<String> getMapProviders() {
return List.copyOf(providers.keySet());
}
@Cacheable(value = "map", key = "{#provider, #latitude, #longitude, #zoom}")
public byte[] getStaticMap(String provider, double latitude, double longitude, int zoom) {
val mapProvider = providers.get(provider.toUpperCase());
if (mapProvider == null) {
throw new UnsupportedOperationException("Map provider not found: " + provider);
}
val imageData = mapProvider.getStaticMap(latitude, longitude, zoom);
cacheImage(provider, latitude, longitude, zoom, imageData);
return imageData;
}
private void cacheImage(String provider, double latitude, double longitude, int zoom, byte[] image) {
storageService.save("mapProvider/" + provider.toUpperCase() + "/" + latitude + "/" + longitude + "/" + zoom + "/image.png", image, MediaType.IMAGE_PNG_VALUE, 60);
}
}

View File

@@ -0,0 +1,29 @@
package com.rdkr.tide_display.backend.images.services.mapProvider;
import com.rdkr.tide_display.backend.apple.Snapshot;
import lombok.RequiredArgsConstructor;
import lombok.val;
import org.springframework.stereotype.Component;
import org.springframework.web.client.RestClient;
@Component("AppleMapKit")
@RequiredArgsConstructor
public class AppleMapKit implements MapProvider {
private final RestClient.Builder restClient;
private final Snapshot snapshot;
@Override
public byte[] getStaticMap(double latitude, double longitude, int zoom) {
try {
val uri = snapshot.generateSnapshotURI(latitude, longitude);
val client = restClient.baseUrl(uri.toString()).build();
return client.get()
.retrieve()
.body(byte[].class);
} catch (Exception e) {
throw new RuntimeException(e);
}
}
}

View File

@@ -0,0 +1,36 @@
package com.rdkr.tide_display.backend.images.services.mapProvider;
import lombok.RequiredArgsConstructor;
import lombok.val;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Component;
import org.springframework.web.client.RestClient;
@Component("GoogleMaps")
@RequiredArgsConstructor
public class GoogleMaps implements MapProvider {
private final RestClient.Builder restClient;
@Value("${GCP_MAP_ID:dummy}")
private String mapId;
@Value("${GCP_API_KEY:dummy}")
private String apiKey;
@Override
public byte[] getStaticMap(double latitude, double longitude, int zoom) {
val client = restClient.baseUrl("https://maps.googleapis.com/maps/api/staticmap").build();
return client.get()
.uri(builder -> builder
.queryParam("center", latitude + "," + longitude)
.queryParam("zoom", "" + zoom)
.queryParam("size", "540x540")
.queryParam("map_id", mapId)
.queryParam("key", apiKey)
.build())
.retrieve()
.body(byte[].class);
}
}

View File

@@ -0,0 +1,31 @@
package com.rdkr.tide_display.backend.images.services.mapProvider;
import lombok.RequiredArgsConstructor;
import lombok.val;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Component;
import org.springframework.web.client.RestClient;
@Component("MapBox")
@RequiredArgsConstructor
public class MapBox implements MapProvider {
private final RestClient.Builder restClient;
@Value("${MAPBOX_TOKEN:dummy}")
private String accessToken;
@Override
public byte[] getStaticMap(double latitude, double longitude, int zoom) {
val client = restClient.baseUrl("https://api.mapbox.com/styles/v1/mapbox/streets-v12/static").build();
return client.get()
.uri(builder -> builder
.path("/" + longitude + "," + latitude + "," + (zoom - 1))
.path("/540x540")
.queryParam("access_token", accessToken)
.build())
.retrieve()
.body(byte[].class);
}
}

View File

@@ -0,0 +1,7 @@
package com.rdkr.tide_display.backend.images.services.mapProvider;
public interface MapProvider {
byte[] getStaticMap(double latitude, double longitude, int zoom);
}

View File

@@ -0,0 +1,3 @@
logging:
level:
com.rdkr: trace