From 13b0b61ed8a05e79bb845ac5a9865864182aa4f6 Mon Sep 17 00:00:00 2001 From: Bartosz Firyn Date: Sat, 11 Nov 2017 22:05:42 +0100 Subject: [PATCH] Redisign IP camera driver and fix FPS calculation --- webcam-capture-drivers/driver-ipcam/pom.xml | 4 +- .../LignanoBeachPushModeIpCameraExample.java | 179 +++++++ .../sarxos/webcam/ds/ipcam/IpCamDevice.java | 501 ++++++++---------- .../webcam/ds/ipcam/impl/IpCamHttpClient.java | 66 --- 4 files changed, 402 insertions(+), 348 deletions(-) create mode 100644 webcam-capture-drivers/driver-ipcam/src/examples/java/LignanoBeachPushModeIpCameraExample.java delete mode 100644 webcam-capture-drivers/driver-ipcam/src/main/java/com/github/sarxos/webcam/ds/ipcam/impl/IpCamHttpClient.java diff --git a/webcam-capture-drivers/driver-ipcam/pom.xml b/webcam-capture-drivers/driver-ipcam/pom.xml index 1b708577..44802d0c 100644 --- a/webcam-capture-drivers/driver-ipcam/pom.xml +++ b/webcam-capture-drivers/driver-ipcam/pom.xml @@ -36,12 +36,12 @@ org.apache.httpcomponents httpclient - 4.2.3 + 4.5.3 org.apache.httpcomponents httpmime - 4.2.3 + 4.5.3 diff --git a/webcam-capture-drivers/driver-ipcam/src/examples/java/LignanoBeachPushModeIpCameraExample.java b/webcam-capture-drivers/driver-ipcam/src/examples/java/LignanoBeachPushModeIpCameraExample.java new file mode 100644 index 00000000..c0bc578d --- /dev/null +++ b/webcam-capture-drivers/driver-ipcam/src/examples/java/LignanoBeachPushModeIpCameraExample.java @@ -0,0 +1,179 @@ +import java.awt.Dimension; +import java.awt.FlowLayout; +import java.awt.event.ActionEvent; +import java.io.File; +import java.io.IOException; +import java.net.MalformedURLException; +import java.util.ArrayList; +import java.util.List; +import java.util.concurrent.Executor; +import java.util.concurrent.Executors; + +import javax.imageio.ImageIO; +import javax.swing.AbstractAction; +import javax.swing.JButton; +import javax.swing.JFrame; +import javax.swing.JLabel; + +import com.github.sarxos.webcam.Webcam; +import com.github.sarxos.webcam.WebcamPanel; +import com.github.sarxos.webcam.WebcamPanel.DrawMode; +import com.github.sarxos.webcam.ds.ipcam.IpCamDeviceRegistry; +import com.github.sarxos.webcam.ds.ipcam.IpCamDriver; +import com.github.sarxos.webcam.ds.ipcam.IpCamMode; + +@SuppressWarnings("serial") +public class LignanoBeachPushModeIpCameraExample extends JFrame { + + // IMPORTANT! For IP camera you have to use IpCamDriver + + static { + Webcam.setDriver(new IpCamDriver()); + } + + // IMPORTANT! IP cameras are not automatically discovered like USB, you have + // to register them manually + + static { + try { + IpCamDeviceRegistry.register("Lignano Beach IP Camera", "http://195.31.81.138/mjpg/video.mjpg", IpCamMode.PUSH); + } catch (MalformedURLException e) { + throw new IllegalStateException(e); + } + } + + /** + * Action to be performed when Snapshot JButton is clicked. + */ + private class SnapMeAction extends AbstractAction { + + public SnapMeAction() { + super("Snapshot"); + } + + @Override + public void actionPerformed(ActionEvent e) { + try { + for (int i = 0; i < webcams.size(); i++) { + Webcam webcam = webcams.get(i); + File file = new File(String.format("test-%d.jpg", i)); + ImageIO.write(webcam.getImage(), "JPG", file); + System.out.format("Image for %s saved in %s \n", webcam.getName(), file); + } + } catch (IOException e1) { + e1.printStackTrace(); + } + } + } + + /** + * Action to be performed when Start JButton is clicked. + */ + private class StartAction extends AbstractAction implements Runnable { + + public StartAction() { + super("Start"); + } + + @Override + public void actionPerformed(ActionEvent e) { + + btStart.setEnabled(false); + btSnapMe.setEnabled(true); + + // remember to start panel asynchronously - otherwise GUI will be + // blocked while OS is opening webcam HW (will have to wait for + // webcam to be ready) and this causes GUI to hang, stop responding + // and repainting + + executor.execute(this); + } + + @Override + public void run() { + + btStop.setEnabled(true); + + for (WebcamPanel panel : panels) { + panel.start(); + } + } + } + + /** + * Action to be performed when Stop JButton is clicked. + */ + private class StopAction extends AbstractAction { + + public StopAction() { + super("Stop"); + } + + @Override + public void actionPerformed(ActionEvent e) { + + btStart.setEnabled(true); + btSnapMe.setEnabled(false); + btStop.setEnabled(false); + + for (WebcamPanel panel : panels) { + panel.stop(); + } + } + } + + private Executor executor = Executors.newSingleThreadExecutor(); + + private List webcams = Webcam.getWebcams(); + private List panels = new ArrayList(); + + private JButton btSnapMe = new JButton(new SnapMeAction()); + private JButton btStart = new JButton(new StartAction()); + private JButton btStop = new JButton(new StopAction()); + + public LignanoBeachPushModeIpCameraExample() { + + super("Lignano Beach IP Camera Example"); + + setLayout(new FlowLayout()); + + JLabel label = new JLabel("Please wait... IP camera user interface initialization in progress"); + add(label); + + pack(); + setVisible(true); + + for (final Webcam webcam : webcams) { + final Dimension size = webcam.getViewSizes()[0]; + webcam.setViewSize(size); + final WebcamPanel panel = new WebcamPanel(webcam, size, false); + panel.setFPSDisplayed(true); + panel.setDrawMode(DrawMode.FIT); + panels.add(panel); + } + + // start application with disable snapshot button - we enable it when + // webcam is started + + btSnapMe.setEnabled(false); + btStop.setEnabled(false); + + for (WebcamPanel panel : panels) { + add(panel); + } + + add(btSnapMe); + add(btStart); + add(btStop); + + label.setVisible(false); + + pack(); + setVisible(true); + setDefaultCloseOperation(JFrame.EXIT_ON_CLOSE); + } + + public static void main(String[] args) { + new LignanoBeachPushModeIpCameraExample(); + } +} diff --git a/webcam-capture-drivers/driver-ipcam/src/main/java/com/github/sarxos/webcam/ds/ipcam/IpCamDevice.java b/webcam-capture-drivers/driver-ipcam/src/main/java/com/github/sarxos/webcam/ds/ipcam/IpCamDevice.java index 8d769a42..f85b11fd 100644 --- a/webcam-capture-drivers/driver-ipcam/src/main/java/com/github/sarxos/webcam/ds/ipcam/IpCamDevice.java +++ b/webcam-capture-drivers/driver-ipcam/src/main/java/com/github/sarxos/webcam/ds/ipcam/IpCamDevice.java @@ -9,6 +9,7 @@ import java.net.URI; import java.net.URISyntaxException; import java.net.URL; +import java.util.concurrent.Semaphore; import javax.imageio.ImageIO; @@ -17,19 +18,25 @@ import org.apache.http.HttpHost; import org.apache.http.HttpResponse; import org.apache.http.auth.AuthScope; +import org.apache.http.auth.Credentials; +import org.apache.http.auth.UsernamePasswordCredentials; import org.apache.http.client.AuthCache; +import org.apache.http.client.CredentialsProvider; +import org.apache.http.client.HttpClient; import org.apache.http.client.methods.HttpGet; import org.apache.http.client.methods.HttpHead; -import org.apache.http.client.protocol.ClientContext; +import org.apache.http.client.protocol.HttpClientContext; import org.apache.http.impl.auth.BasicScheme; import org.apache.http.impl.client.BasicAuthCache; -import org.apache.http.protocol.BasicHttpContext; +import org.apache.http.impl.client.BasicCredentialsProvider; +import org.apache.http.impl.client.HttpClientBuilder; +import org.apache.http.protocol.HttpContext; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import com.github.sarxos.webcam.WebcamDevice; +import com.github.sarxos.webcam.WebcamDevice.FPSSource; import com.github.sarxos.webcam.WebcamException; -import com.github.sarxos.webcam.ds.ipcam.impl.IpCamHttpClient; import com.github.sarxos.webcam.ds.ipcam.impl.IpCamMJPEGStream; @@ -38,170 +45,162 @@ * * @author Bartosz Firyn (SarXos) */ -public class IpCamDevice implements WebcamDevice { +public class IpCamDevice implements WebcamDevice, FPSSource { /** * Logger. */ private static final Logger LOG = LoggerFactory.getLogger(IpCamDevice.class); - private final class PushImageReader implements Runnable { + private interface ImageReader extends FPSSource { - private final Object lock = new Object(); - private IpCamMJPEGStream stream = null; - private BufferedImage image = null; - private boolean running = true; - private WebcamException exception = null; - private HttpGet get = null; - private URI uri = null; + BufferedImage readImage() throws InterruptedException; - public PushImageReader(URI uri) { - this.uri = uri; - stream = new IpCamMJPEGStream(requestStream(uri)); - } - - private InputStream requestStream(URI uri) { - - BasicHttpContext context = new BasicHttpContext(); - - IpCamAuth auth = getAuth(); - if (auth != null) { - AuthCache cache = new BasicAuthCache(); - cache.put(new HttpHost(uri.getHost()), new BasicScheme()); - context.setAttribute(ClientContext.AUTH_CACHE, cache); - } + void halt(); - try { - get = new HttpGet(uri); - - HttpResponse respone = client.execute(get, context); - HttpEntity entity = respone.getEntity(); + void init(); + } - Header ct = entity.getContentType(); - if (ct == null) { - throw new WebcamException("Content Type header is missing"); - } + private final class PushImageReader extends Thread implements ImageReader { - if (ct.getValue().startsWith("image/")) { - throw new WebcamException("Cannot read images in PUSH mode, change mode to PULL"); - } + private final URI uri; + private volatile boolean running = true; + private volatile WebcamException exception = null; + private volatile BufferedImage image = null; + private BufferedImage tmp; + private final Semaphore semaphore = new Semaphore(1); + private volatile double fps = 0; - return entity.getContent(); + public PushImageReader(final URI uri) { + this.uri = uri; + this.semaphore.drainPermits(); + this.setDaemon(true); + } + private IpCamMJPEGStream request(final URI uri) { + try { + return new IpCamMJPEGStream(get(uri, true)); } catch (Exception e) { - throw new WebcamException("Cannot download image", e); + throw new WebcamException("Cannot download image. " + e.getMessage(), e); } } - private volatile boolean processing = true; - @Override public void run() { - while (running) { - - if (stream.isClosed()) { - break; - } - - try { - - LOG.trace("Reading MJPEG frame"); + long t1; + long t2; - processing = false; - try { - BufferedImage image = stream.readFrame(); - if (image != null) { - this.image = image; - } else { - LOG.error("No image received from the stream"); - } - } finally { - synchronized (lock) { - lock.notifyAll(); + while (running) { + try (final IpCamMJPEGStream stream = request(uri)) { + do { + t1 = System.currentTimeMillis(); + if ((tmp = stream.readFrame()) != null) { + image = tmp; + semaphore.release(); } - processing = false; - } - + t2 = System.currentTimeMillis(); + fps = (double) 1000 / (t2 - t1 + 1); + } while (running && !stream.isClosed()); } catch (IOException e) { + if (e instanceof EOFException) { // EOF, ignore error and recreate stream + continue; + } + exception = new WebcamException("Cannot read MJPEG frame", e); + } + } + } - // case when someone manually closed stream, do not log - // exception, this is normal behavior + @Override + public BufferedImage readImage() throws InterruptedException { + if (exception != null && failOnError) { + throw exception; + } + semaphore.acquire(); + try { + return image; + } finally { + semaphore.release(); + } + } - if (stream.isClosed()) { - LOG.debug("Stream already closed, returning"); - return; - } + @Override + public void halt() { + running = false; + semaphore.release(); + } - if (e instanceof EOFException) { + @Override + public void init() { + start(); + } - LOG.debug("EOF detected, recreating MJPEG stream"); + @Override + public double getFPS() { + return fps; + } + } - get.releaseConnection(); + private final class PullImageReader implements ImageReader { - try { - stream.close(); - } catch (IOException ioe) { - throw new WebcamException(ioe); - } + private final URI uri; + private double fps = 0; - stream = new IpCamMJPEGStream(requestStream(uri)); + public PullImageReader(final URI uri) { + this.uri = uri; + } - continue; - } + @Override + public BufferedImage readImage() throws InterruptedException { - LOG.error("Cannot read MJPEG frame", e); + long t1; + long t2; - if (failOnError) { - exception = new WebcamException("Cannot read MJPEG frame", e); - throw exception; - } - } + t1 = System.currentTimeMillis(); + try (final InputStream is = request(uri)) { + return ImageIO.read(is); + } catch (IOException e) { + throw new WebcamException(e); + } finally { + t2 = System.currentTimeMillis(); + fps = (double) 1000 / (t2 - t1 + 1); } + } + private InputStream request(final URI uri) { try { - stream.close(); - } catch (IOException e) { - LOG.debug("Some nasty exception when closing MJPEG stream", e); + return get(uri, false); + } catch (Exception e) { + throw new WebcamException("Cannot download image", e); } + } + @Override + public void halt() { + // do nothing, no need to stop this reader } - public BufferedImage getImage() { - if (exception != null) { - throw exception; - } - if (image == null) { - try { - while (processing) { - synchronized (lock) { - lock.wait(); - } - } - } catch (InterruptedException e) { - throw new WebcamException("Reader thread interrupted", e); - } catch (Exception e) { - throw new RuntimeException("Problem waiting on lock", e); - } - } - return image; + @Override + public void init() { + // do nothing, no need to start this one } - public void stop() { - running = false; + @Override + public double getFPS() { + return fps; } } - private String name = null; - private URL url = null; - private IpCamMode mode = null; - private IpCamAuth auth = null; - private IpCamHttpClient client = new IpCamHttpClient(); - private PushImageReader pushReader = null; + private final String name; + private final URL url; + private final IpCamMode mode; + private final IpCamAuth auth; private boolean failOnError = false; - private volatile boolean open = false; - private volatile boolean disposed = false; + private final HttpClient client; + private ImageReader reader; + + private boolean open = false; private Dimension[] sizes = null; private Dimension size = null; @@ -228,31 +227,90 @@ public IpCamDevice(String name, URL url, IpCamMode mode, IpCamAuth auth) { this.url = url; this.mode = mode; this.auth = auth; + this.client = createClient(); + } + + protected static final URL toURL(String url) { + if (!url.startsWith("http://")) { + url = "http://" + url; + } + try { + return new URL(url); + } catch (MalformedURLException e) { + throw new WebcamException(String.format("Incorrect URL '%s'", url), e); + } + } - if (auth != null) { - AuthScope scope = new AuthScope(new HttpHost(url.getHost().toString())); - client.getCredentialsProvider().setCredentials(scope, auth); + private static final URI toURI(URL url) { + try { + return url.toURI(); + } catch (URISyntaxException e) { + throw new WebcamException(e); } } - public IpCamHttpClient getClient() { + public HttpClient getClient() { return client; } - protected static final URL toURL(String url) { + private HttpClient createClient() { + return HttpClientBuilder.create().build(); + } - String base = null; - if (url.startsWith("http://")) { - base = url; - } else { - base = String.format("http://%s", url); + private ImageReader createReader() { + switch (mode) { + case PULL: + return new PullImageReader(toURI(url)); + case PUSH: + return new PushImageReader(toURI(url)); + default: + throw new WebcamException("Unsupported mode " + mode); } + } - try { - return new URL(base); - } catch (MalformedURLException e) { - throw new WebcamException(String.format("Incorrect URL '%s'", url), e); + private HttpContext context() { + + final IpCamAuth auth = getAuth(); + + if (auth == null) { + return null; + } + + final URI uri = toURI(url); + final HttpHost host = new HttpHost(uri.getHost(), uri.getPort(), uri.getScheme()); + final Credentials credentials = new UsernamePasswordCredentials(auth.getUserName(), auth.getPassword()); + final CredentialsProvider provider = new BasicCredentialsProvider(); + provider.setCredentials(AuthScope.ANY, credentials); + + final AuthCache cache = new BasicAuthCache(); + cache.put(host, new BasicScheme()); + + final HttpClientContext context = HttpClientContext.create(); + context.setCredentialsProvider(provider); + context.setAuthCache(cache); + + return context; + } + + private InputStream get(final URI uri, boolean withoutImageMime) throws UnsupportedOperationException, IOException { + + final HttpGet get = new HttpGet(uri); + final HttpResponse respone = client.execute(get, context()); + final HttpEntity entity = respone.getEntity(); + + // normal jpeg return image/jpeg as opposite to mjpeg + + if (withoutImageMime) { + final Header contentType = entity.getContentType(); + if (contentType == null) { + throw new WebcamException("Content Type header is missing"); + } + if (contentType.getValue().startsWith("image/")) { + throw new WebcamException("Cannot read images in PUSH mode, change mode to PULL " + contentType); + } } + + return entity.getContent(); } @Override @@ -273,7 +331,7 @@ public Dimension[] getResolutions() { int attempts = 0; do { - BufferedImage img = getImage(); + final BufferedImage img = getImage(); if (img != null) { sizes = new Dimension[] { new Dimension(img.getWidth(), img.getHeight()) }; break; @@ -283,7 +341,7 @@ public Dimension[] getResolutions() { close(); if (sizes == null) { - throw new WebcamException("Cannot get initial image from IP camera device " + getName()); + sizes = new Dimension[] { new Dimension(0, 0) }; } return sizes; @@ -308,109 +366,14 @@ public void setResolution(Dimension size) { @Override public synchronized BufferedImage getImage() { - - if (!open) { - return null; - } - - if (mode == null) { - throw new IllegalStateException("Camera mode cannot be null!"); - } - - switch (mode) { - case PULL: - return getImagePullMode(); - case PUSH: - return getImagePushMode(); - } - - throw new WebcamException(String.format("Unsupported mode %s", mode)); - } - - private BufferedImage getImagePushMode() { - - if (pushReader == null) { - - URI uri = null; + if (open) { try { - uri = getURL().toURI(); - } catch (URISyntaxException e) { - throw new WebcamException(String.format("Incorrect URI syntax '%s'", uri), e); - } - - pushReader = new PushImageReader(uri); - - // TODO: change to executor - - Thread thread = new Thread(pushReader, String.format("%s-reader", getName())); - thread.setDaemon(true); - thread.start(); - } - - return pushReader.getImage(); - } - - private BufferedImage getImagePullMode() { - - synchronized (this) { - - HttpGet get = null; - URI uri = null; - - try { - uri = getURL().toURI(); - } catch (URISyntaxException e) { - throw new WebcamException(String.format("Incorrect URI syntax '%s'", uri), e); - } - - BasicHttpContext context = new BasicHttpContext(); - - IpCamAuth auth = getAuth(); - if (auth != null) { - AuthCache cache = new BasicAuthCache(); - cache.put(new HttpHost(uri.getHost()), new BasicScheme()); - context.setAttribute(ClientContext.AUTH_CACHE, cache); - } - - try { - get = new HttpGet(uri); - - HttpResponse respone = client.execute(get, context); - HttpEntity entity = respone.getEntity(); - - Header ct = entity.getContentType(); - if (ct == null) { - throw new WebcamException("Content Type header is missing"); - } - - if (ct.getValue().startsWith("multipart/")) { - throw new WebcamException("Cannot read MJPEG stream in PULL mode, change mode to PUSH"); - } - - InputStream is = entity.getContent(); - if (is == null) { - return null; - } - - return ImageIO.read(is); - - } catch (IOException e) { - - // fall thru, it means we closed stream - if (e.getMessage().equals("closed")) { - return null; - } - - throw new WebcamException("Cannot download image", e); - - } catch (Exception e) { - throw new WebcamException("Cannot download image", e); - } finally { - if (get != null) { - get.releaseConnection(); - } + return reader.readImage(); + } catch (InterruptedException e) { + throw new WebcamException(e); } } + return null; } /** @@ -421,53 +384,38 @@ private BufferedImage getImagePullMode() { * @return True if camera is online, false otherwise */ public boolean isOnline() { - LOG.debug("Checking online status for {} at {}", getName(), getURL()); - - URI uri = null; try { - uri = getURL().toURI(); - } catch (URISyntaxException e) { - throw new WebcamException(String.format("Incorrect URI syntax '%s'", uri), e); - } - - HttpHead head = new HttpHead(uri); - - HttpResponse response = null; - try { - response = client.execute(head); + return client + .execute(new HttpHead(toURI(getURL()))) + .getStatusLine() + .getStatusCode() != 404; } catch (Exception e) { return false; - } finally { - if (head != null) { - head.releaseConnection(); - } } - - return response.getStatusLine().getStatusCode() != 404; } @Override public void open() { - if (disposed) { - LOG.warn("Device cannopt be open because it's already disposed"); - return; + if (!open) { + + reader = createReader(); + reader.init(); + + try { + reader.readImage(); + } catch (InterruptedException e) { + throw new WebcamException(e); + } } open = true; } @Override public void close() { - - if (!open) { - return; + if (open) { + reader.halt(); } - - if (pushReader != null) { - pushReader.stop(); - pushReader = null; - } - open = false; } @@ -483,29 +431,22 @@ public IpCamAuth getAuth() { return auth; } - public void setAuth(IpCamAuth auth) { - if (auth != null) { - URL url = getURL(); - AuthScope scope = new AuthScope(url.getHost(), url.getPort()); - client.getCredentialsProvider().setCredentials(scope, auth); - } - } - - public void resetAuth() { - client.getCredentialsProvider().clear(); - } - public void setFailOnError(boolean failOnError) { this.failOnError = failOnError; } @Override public void dispose() { - disposed = true; + // ignore } @Override public boolean isOpen() { return open; } + + @Override + public double getFPS() { + return reader.getFPS(); + } } diff --git a/webcam-capture-drivers/driver-ipcam/src/main/java/com/github/sarxos/webcam/ds/ipcam/impl/IpCamHttpClient.java b/webcam-capture-drivers/driver-ipcam/src/main/java/com/github/sarxos/webcam/ds/ipcam/impl/IpCamHttpClient.java deleted file mode 100644 index 8381fe63..00000000 --- a/webcam-capture-drivers/driver-ipcam/src/main/java/com/github/sarxos/webcam/ds/ipcam/impl/IpCamHttpClient.java +++ /dev/null @@ -1,66 +0,0 @@ -package com.github.sarxos.webcam.ds.ipcam.impl; - -import org.apache.http.HttpHost; -import org.apache.http.conn.params.ConnRoutePNames; -import org.apache.http.impl.client.DefaultHttpClient; -import org.apache.http.impl.conn.PoolingClientConnectionManager; -import org.apache.http.params.HttpConnectionParams; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; - - -public class IpCamHttpClient extends DefaultHttpClient { - - /** - * Logger. - */ - private static final Logger LOG = LoggerFactory.getLogger(IpCamHttpClient.class); - - /** - * Key for the proxy host property. - */ - public static final String PROXY_HOST_KEY = "http.proxyHost"; - - /** - * Key for the proxy port number property. - */ - public static final String PROXY_PORT_KEY = "http.proxyPort"; - - public static final String SO_TIMEOUT = "http.socket.timeout"; - public static final String CONNECTION_TIMEOUT = "http.connection.timeout"; - - private HttpHost proxy = null; - - public IpCamHttpClient() { - - super(new PoolingClientConnectionManager()); - - // configure proxy if any - - String proxyHost = System.getProperty(PROXY_HOST_KEY); - String proxyPort = System.getProperty(PROXY_PORT_KEY); - - if (proxyHost != null && proxyPort != null) { - - LOG.debug("Setting proxy '{}:{}'", proxyHost, proxyPort); - - proxy = new HttpHost(proxyHost, Integer.parseInt(proxyPort), "http"); - - setProxy(proxy); - } - - String soTimeout = System.getProperty(SO_TIMEOUT); - if (soTimeout != null) { - HttpConnectionParams.setSoTimeout(getParams(), Integer.parseInt(soTimeout)); - } - - String connectionTimeout = System.getProperty(CONNECTION_TIMEOUT); - if (connectionTimeout != null) { - HttpConnectionParams.setConnectionTimeout(getParams(), Integer.parseInt(connectionTimeout)); - } - } - - public void setProxy(HttpHost proxy) { - getParams().setParameter(ConnRoutePNames.DEFAULT_PROXY, proxy); - } -}