<?php
namespace App\Controller\Api;
// use OpenApi\Annotations as OA;
use App\Entity\Gpio;
use App\Entity\Sensor;
use App\Entity\SmsLog;
use App\Entity\Trade;
use App\Repository\SmsLogRepository;
use App\Service\ObserverDeployManifest;
use App\Service\SensorRemoteService;
use App\Service\SmsLogger;
use App\Service\WhereEverSmsService;
use FOS\RestBundle\Controller\AbstractFOSRestController;
use FOS\RestBundle\Controller\Annotations as Rest;
use Psr\Log\LoggerInterface;
use Sensio\Bundle\FrameworkExtraBundle\Configuration\Route;
use OpenApi\Annotations as OA;
use Symfony\Component\HttpClient\HttpClient;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\HttpKernel\KernelInterface;
use Symfony\Component\Mercure\HubInterface;
use Symfony\Component\Mercure\Update;
use Symfony\Component\Serializer\Encoder\JsonEncoder;
use Symfony\Component\Serializer\Normalizer\ObjectNormalizer;
use Symfony\Component\Serializer\Serializer;
class SensorController extends AbstractFOSRestController
{
protected $logger;
protected $serializer;
private $appKernel;
private $sensorRemoteService;
private HubInterface $hub;
public function __construct(LoggerInterface $logger, KernelInterface $appKernel, SensorRemoteService $sensorRemoteService, HubInterface $hub)
{
$encoders = [new JsonEncoder()];
$normalizers = [new ObjectNormalizer()];
$this->logger = $logger;
$this->appKernel = $appKernel;
$this->serializer = new Serializer($normalizers, $encoders);
$this->sensorRemoteService = $sensorRemoteService;
$this->hub = $hub;
}
public function index(LoggerInterface $logger)
{
return $this->render('api/index.html.twig', [
'controller_name' => 'ApiController',
]);
}
/**
* @Route("/sensor/register", name="sensor_register", methods={"POST"})
*
* @OA\Post(
* summary="Register new sensor",
*
*
* @OA\Parameter(
* name="body",
* description="Post data.",
* in="body",
* required=true,
*
* @OA\Schema(
* type="string",
* required={"serial", "createdAt"},
*
* @OA\Property(
* property="serial",
* type="string",
* minLength=1,
* example="10000000b230f68d"
* ),
* @OA\Property(
* property="createdAt",
* type="datetime",
* minLength=1,
* example="2020-05-10 12:13:01"
* )
* )
* ),
*
* @OA\Response(
* response=200,
* description="Returns status 200 and the modified contact.",
*
* @OA\Schema(
* type="object",
* properties={
*
* @OA\Property(property="id", type="integer"),
* }
* )
* ),
*
* @OA\Response(
* response=404,
* description="Returns status 404 if there is no contact with the given id."
* )
* )
*
* @return View
*/
public function register(Request $request)
{
$repository = $this->getDoctrine()->getRepository(Sensor::class);
$em = $this->getDoctrine()->getManager();
$parameters = json_decode($request->getContent(), true);
$serial = $parameters['serial'];
$createdDate = $parameters['createdAt'];
$trade = $this->getDoctrine()->getRepository(Trade::class)->find($parameters['tradeId']);
if ($sensor = $repository->findOneBySerial($serial)) {
// check if sensor has already a port
if (!$sensor->getPort()) {
$port = $repository->getNextFreePort($trade);
$sensor->setPort($port);
$em->persist($sensor);
$em->flush();
// sensor is already but has a new port so we need to give an state ok back
$objContent = [
'id' => $sensor->getId(),
'serial' => $sensor->getSerial(),
'createdAt' => $sensor->getCreatedAt()->format('Y-m-d H:i:s'),
'port' => $sensor->getPort(),
];
$jsonContent = $this->serializer->serialize($objContent, 'json');
$response = new Response($jsonContent);
$response->headers->set('Content-Type', 'application/json');
$response->setStatusCode(Response::HTTP_CREATED);
return $response;
}
// sensor is already registered
$objContent = [
'id' => $sensor->getId(),
'serial' => $sensor->getSerial(),
'createdAt' => $sensor->getCreatedAt()->format('Y-m-d H:i:s'),
'port' => $sensor->getPort(),
];
$jsonContent = $this->serializer->serialize($objContent, 'json');
$response = new Response($jsonContent);
// / $response = new Response();
$response->headers->set('Content-Type', 'application/json');
$response->setStatusCode(Response::HTTP_CONFLICT);
// $response->setStatusCode(Response::HTTP_INTERNAL_SERVER_ERROR);
return $response;
}
$sensor = new Sensor();
$sensor->setSerial($serial);
$sensor->setCreatedAt(new \DateTime());
$sensor->setLastOnline(new \DateTime());
$sensor->setTrade($trade);
$sensor->setObserverState(false);
$sensor->setInstalled(0);
$sensor->setState(Sensor::STATE_ONLINE);
$port = $repository->getNextFreePort($trade);
$sensor->setPort($port);
$em->persist($sensor);
$em->flush();
$objContent = [
'id' => $sensor->getId(),
'serial' => $sensor->getSerial(),
'createdAt' => $sensor->getCreatedAt()->format('Y-m-d H:i:s'),
'port' => $sensor->getPort(),
];
$jsonContent = $this->serializer->serialize($objContent, 'json');
$response = new Response($jsonContent);
$response->headers->set('Content-Type', 'application/json');
$response->setStatusCode(Response::HTTP_CREATED);
return $response;
}
/**
* @Route("/sensor/lastOnline", name="sensor_lastonline", methods={"PUT"})
*
* @OA\Post(
* summary="Set last online datetime for sensor.",
*
*
* @OA\Parameter(
* name="body",
* description="Post data.",
* in="body",
* required=true,
*
* @OA\Schema(type="string")
* ),
*
* @OA\Response(
* response=200,
* description="Returns status 200 and the modified contact.",
*
* @OA\Schema(
* type="object",
* properties={
*
* @OA\Property(property="id", type="integer"),
* }
* )
* ),
*
* @OA\Response(
* response=404,
* description="Returns status 404 if there is no contact with the given id."
* )
* )
*/
public function lastOnlineAction(Request $request): JsonResponse
{
$responseArray = ['ok' => 'ok'];
// return new JsonResponse($responseArray);
$requestBody = $request->getContent();
$data = json_decode($requestBody);
$em = $this->getDoctrine()->getManager();
$sensor = $em->getRepository(Sensor::class)->findOneBy(['serial' => $data->serial]);
if ($sensor) {
// $this->logger->error('SensorController API: lastOnlineAction: '.$sensor->getId());
$lastOnlineDateTime = new \DateTime();
$sensor->setLastOnline($lastOnlineDateTime);
$sensor->setState(1);
$em->persist($sensor);
$em->flush();
$command = $sensor->getPendingCommand();
if ($command) {
$sensor->setPendingCommand(null);
$em->flush();
}
$responseArray = ['ok' => 'ok', 'command' => $command];
} else {
// $this->logger->error('SensorController API: lastOnlineAction: ERROR: '.$data->serial);
$responseArray = ['error' => 'Sensor '.$data->serial.' not found'];
}
return new JsonResponse($responseArray);
}
/**
* @Route("/sensor/keepAlive", name="sensor_keep_alive", methods={"GET"})
*
* KeepAlive-Ping vom Sensor alle 5 Minuten.
* Aufruf: GET /sensor/keepAlive?serial=SENSOR_SERIAL
*
* Ergänzt lastOnlineAction (PUT + JSON-Body): NB-IoT-Sensoren unterstützen
* je nach Firmware-Stand keinen HTTP-PUT mit JSON-Body. Ein einfacher GET-Request
* mit Query-Parameter ist auf allen Modulen (z.B. SIM7000, BC66) per AT-Befehl
* ohne weitere Konfiguration realisierbar.
*/
public function keepAliveAction(Request $request): JsonResponse
{
$serial = $request->query->get('serial');
if (!$serial) {
return new JsonResponse(['error' => 'serial fehlt'], Response::HTTP_BAD_REQUEST);
}
$em = $this->getDoctrine()->getManager();
$sensor = $em->getRepository(Sensor::class)->findOneBy(['serial' => $serial]);
if (!$sensor) {
return new JsonResponse(['error' => 'Sensor nicht gefunden: '.$serial], Response::HTTP_NOT_FOUND);
}
$now = new \DateTime();
$sensor->setLastOnline($now);
$sensor->setState(1);
$em->flush();
$this->logger->info('KeepAlive: Sensor '.$serial.' ('.$sensor->getId().')', [
'sensor_id' => $sensor->getId(),
'ts' => $now->format('Y-m-d H:i:s'),
]);
try {
$this->hub->publish(new Update(
'https://acobesmart.com/mqtt_monitor',
json_encode([
'type' => 'heartbeat',
'source' => 'http',
'sensor_serial' => $serial,
'sensor_id' => $sensor->getId(),
'time' => $now->format('H:i:s'),
], JSON_THROW_ON_ERROR)
));
} catch (\Throwable $e) {
// Mercure nicht erreichbar — Request läuft weiter
}
return new JsonResponse(['ok' => 'ok', 'ts' => $now->format('Y-m-d H:i:s')]);
}
/**
* @Route("/sensor/observerLastOnline", name="observer_lastonline", methods={"PUT"})
*
* @OA\Put(
* summary="Set last online datetime for sensor.",
*
*
* @OA\Parameter(
* name="body",
* description="Post data.",
* in="body",
* required=true,
*
* @OA\Schema(
* type="string",
* required={"sensorId"},
*
* @OA\Property(
* property="sensorId",
* type="string",
* minLength=1,
* example=1
* ),
* )
* ),
*
* @OA\Response(
* response=200,
* description="Returns status 200 and the modified contact.",
*
* @OA\Schema(
* type="object",
* properties={
*
* @OA\Property(property="id", type="integer"),
* }
* )
* ),
*
* @OA\Response(
* response=404,
* description="Returns status 404 if there is no contact with the given id."
* )
* )
*/
public function observerLastOnlineAction(Request $request): JsonResponse
{
$requestBody = $request->getContent();
$data = json_decode($requestBody);
$em = $this->getDoctrine()->getManager();
$sensor = false;
if ($data->sensorId) {
$sensor = $em->getRepository(Sensor::class)->find($data->sensorId);
}
if ($sensor) {
$lastOnlineDate = new \DateTime();
$sensor->setObserverLastOnline($lastOnlineDate);
// $sensor->setObserverState(1);
$em->persist($sensor);
$em->flush();
$responseArray = ['status' => 'ok'];
} else {
$responseArray = ['error' => 'Sensor '.$data->sensorId.' not found'];
}
return new JsonResponse($responseArray);
}
/**
* @Route("/sensor/getGpios", name="sensor_get_gpios", methods={"GET"})
*
* @OA\Get(
* summary="Get registered Gpios",
*
*
* @OA\Parameter(
* name="body",
* description="Post data.",
* in="body",
* required=true,
*
* @OA\Schema(
* type="string",
* required={"sensorId"},
*
* @OA\Property(
* property="sensorId",
* type="string",
* minLength=1,
* example=1
* ),
* )
* ),
*
* @OA\Response(
* response=200,
* description="Returns status 200 and the modified contact.",
*
* @OA\Schema(
* type="object",
* properties={
*
* @OA\Property(property="id", type="integer"),
* }
* )
* ),
*
* @OA\Response(
* response=404,
* description="Returns status 404 if there is no contact with the given id."
* )
* )
*/
public function getGpiosAction(Request $request)
{
$this->logger->debug('SensorController API: getGpiosAction');
$requestBody = $request->getContent();
$data = json_decode($requestBody);
$em = $this->getDoctrine()->getManager();
$sensor = false;
// echo "HIER: ".$data->serial; exit();
if ($data->serial) {
$sensor = $em->getRepository(Sensor::class)->findOneBySerial($data->serial);
}
$gpios = [];
$responseArray = [];
if ($sensor) {
$this->logger->debug('SensorController API: sensor found');
if (true == $sensor->getInstalled()) {
$gpios = $sensor->getGpios();
foreach ($gpios as $gpio) {
if (3000 != $gpio->getNumber()) {
$responseArray[(int) $gpio->getNumber()] = $gpio->getTypeReadable();
}
}
} else {
switch ($sensor->getTrade()->getName()) {
case 'Aufzug':
// sensor not installed so give first all gpios back for installation
$responseArray[Gpio::GPIO_TYPE_POWER] = 'Netz';
$responseArray[Gpio::GPIO_TYPE_SECURITY_CIRCUIT_FRONT] = 'Sicherheitskreis vor den Türen';
$responseArray[Gpio::GPIO_TYPE_SECURITY_CIRCUIT_BACK] = 'Sicherheitskreis nach den Türen';
$responseArray[Gpio::GPIO_TYPE_TRIP_UP] = 'Fahrt auf';
$responseArray[Gpio::GPIO_TYPE_TRIP_DOWN] = 'Fahrt ab';
$responseArray[Gpio::GPIO_TYPE_SECURITY_CIRCUIT_HOISTWAY_DOOR] = 'Sicherheitskreis Schachttüren';
// $responseArray[Gpio::GPIO_TYPE_SECURITY_CIRCUIT_HOISTWAY_DOOR] = 'Reserve';
$responseArray[Gpio::GPIO_TYPE_FLUSHNESS] = 'Bündig';
$responseArray[Gpio::GPIO_TYPE_CABINLIGHT] = 'Kabinenbeleuchtung';
$responseArray[Gpio::GPIO_TYPE_COLLECTIVE_DISORDER] = 'Sammelstörung';
$responseArray[Gpio::GPIO_TYPE_SMOKE_EXTRACTION_COLLECTIVE_DISORDER] = 'Tür 1';
$responseArray[Gpio::GPIO_TYPE_WATERLEVEL_WARNING] = 'Wasserstandsmelder';
$responseArray[Gpio::GPIO_TYPE_DOOR_TWO_CLOSE] = 'Tür 2';
$responseArray[Gpio::GPIO_TYPE_DOOR_TWO_OPEN] = 'Tür 2 auf';
break;
case 'Nea':
break;
case 'Pumpen':
$responseArray[17] = '17';
$responseArray[4] = '4';
$responseArray[14] = '14';
$responseArray[15] = '15';
$responseArray[18] = '18';
$responseArray[23] = '23';
$responseArray[24] = '24';
$responseArray[25] = '25';
$responseArray[8] = '8';
$responseArray[7] = '7';
$responseArray[27] = '27';
$responseArray[22] = '22';
$responseArray[12] = '12';
$responseArray[16] = '16';
// $responseArray[20] = '20';
// $responseArray[21] = '21';
$responseArray[26] = '26';
$responseArray[19] = '19';
$responseArray[13] = '13';
$responseArray[6] = '6';
break;
}
}
$jsonContent = $this->serializer->serialize($responseArray, 'json');
$response = new Response($jsonContent);
$response->headers->set('Content-Type', 'application/json');
// $response->setStatusCode(Response::HTTP_CREATED);
return $response;
}
$this->logger->debug('SensorController API: sensor NOT found');
$responseArray = ['error' => 'Sensor '.$data->serial.' not found'];
return new JsonResponse($responseArray);
}
/**
* @Route("/sensor/observerVersion", name="observer_version", methods={"PUT"})
*
* @OA\Put(
* summary="Set version of script running at sensor.",
*
*
* @OA\Parameter(
* name="body",
* description="Post data.",
* in="body",
* required=true,
*
* @OA\Schema(
* type="string",
* required={"serial", "version"},
*
* @OA\Property(
* property="serial",
* type="string",
* minLength=1,
* example=1
* ),
* @OA\Property(
* property="version",
* type="string",
* minLength=1,
* example=1
* ),
* )
* ),
*
* @OA\Response(
* response=200,
* description="Returns status 200 and the modified contact.",
*
* @OA\Schema(
* type="object",
* properties={
*
* @OA\Property(property="id", type="integer"),
* }
* )
* ),
*
* @OA\Response(
* response=404,
* description="Returns status 404 if there is no contact with the given id."
* )
* )
*/
public function observerVersionAction(Request $request): JsonResponse
{
$requestBody = $request->getContent();
$data = json_decode($requestBody);
$em = $this->getDoctrine()->getManager();
$sensorRepo = $em->getRepository(Sensor::class);
$sensor = $em->getRepository(Sensor::class)->findOneBy(['serial' => $data->serial]);
// Manche Observer melden die Version mit angehängtem Zeilenumbruch aus der .version-Datei
$version = trim($data->version);
if ($sensor) {
$sensor->setObserverVersion($version);
$em->persist($sensor);
$em->flush();
$responseArray = ['status' => 'ok'];
} else {
$responseArray = ['error' => 'Sensor '.$data->serial.' not found'];
}
return new JsonResponse($responseArray);
}
/**
* @Route("/sensor/getSensorId", name="get_sensor_id", methods={"GET"})
*
* @OA\Get(
* summary="Get internal ID of sensor",
*
*
* @OA\Parameter(
* name="body",
* description="Post data.",
* in="body",
* required=true,
*
* @OA\Schema(
* type="string",
* required={"serial"},
*
* @OA\Property(
* property="serial",
* type="string",
* minLength=1,
* example=1
* ),
* )
* ),
*
* @OA\Response(
* response=200,
* description="Returns status 200 and the modified contact.",
*
* @OA\Schema(
* type="object",
* properties={
*
* @OA\Property(property="id", type="integer"),
* }
* )
* ),
*
* @OA\Response(
* response=404,
* description="Returns status 404 if there is no contact with the given id."
* )
* )
*/
public function getSensorIdAction(Request $request): JsonResponse
{
$requestBody = $request->getContent();
$data = json_decode($requestBody);
$em = $this->getDoctrine()->getManager();
$sensorRepo = $em->getRepository(Sensor::class);
$sensor = $em->getRepository(Sensor::class)->findOneBy(['serial' => $data->serial]);
if ($sensor) {
$responseArray = ['id' => $sensor->getId()];
} else {
$responseArray = ['error' => 'Sensor '.$data->serial.' not found'];
}
return new JsonResponse($responseArray);
}
/**
* @Route("/sensor/sendSMS/{action}/{sensor}", name="sensor_send_sms", methods={"POST"})
*
* @param mixed $action
* @param mixed $sensor
*/
public function sendSMS($action, Sensor $sensor)
{
if ($sensor->getPhone()) {
$client = HttpClient::create();
switch ($action) {
case 'test':
$query['action'] = 'Test';
break;
case 'reboot':
$query['action'] = 'Reboot';
break;
case 'restartObserver':
$query['action'] = 'Restart';
break;
case 'reconnect':
$query['action'] = 'Reconnect';
break;
}
$query = ['phone' => $sensor->getPhone(), 'action' => $query['action']];
$response = $client->request('POST', 'http://acosms.duckdns.org:8878/index.php', [
// these values are automatically encoded before including them in the URL
'body' => $query,
]);
if ('OK' == trim($response->getContent())) {
$response = ['state' => 'OK', 'msg' => 'OK'];
} else {
$response = ['state' => 'error', 'msg' => trim($response->getContent())];
}
} else {
$response = ['state' => 'error', 'msg' => 'No phone number given!'];
}
return new JsonResponse($response);
}
/** Sicherheits-Deckel gegen Massenversand im Batch. */
private const SMS_BATCH_MAX = 100;
/**
* Reboot-SMS über WhereEver/Jasper an die ICCID des Sensors (günstiger Weg als normale SMS),
* Abfrage des Zustellstatus (getSmsDetail) und Protokollierung im SMS-Log.
*
* @Route("/sensor/rebootSmsWherever/{sensor}", name="sensor_reboot_sms_wherever", methods={"POST"})
*/
public function rebootSmsWherever(Sensor $sensor, WhereEverSmsService $sms, SmsLogger $smsLogger): JsonResponse
{
$this->denyAccessUnlessGranted('ROLE_SUPER_ADMIN');
return new JsonResponse($this->doCommandSms($sensor, 'Reboot', $sms, $smsLogger));
}
/**
* Steuer-SMS mit frei wählbarem Kommando. Sinnvoll, weil ein Reboot nur bei flüchtigen
* Störungen hilft: Läuft der Pi noch, sind aber die Observer-Dienste gestorben oder hängt
* die Einwahl, wirken "Restart" bzw. "Reconnect" gezielter. Zulässige Werte stehen in
* WhereEverSmsService::COMMANDS; alles andere wird abgewiesen, bevor Kosten entstehen.
*
* @Route("/sensor/commandSmsWherever/{sensor}/{command}", name="sensor_command_sms_wherever", methods={"POST"})
*/
public function commandSmsWherever(Sensor $sensor, string $command, WhereEverSmsService $sms, SmsLogger $smsLogger): JsonResponse
{
$this->denyAccessUnlessGranted('ROLE_SUPER_ADMIN');
return new JsonResponse($this->doCommandSms($sensor, $command, $sms, $smsLogger));
}
/**
* Batch: Reboot-SMS an alle ausgewählten Sensoren (per Checkbox aus der Sensor-Liste).
*
* @Route("/sensor/rebootSmsBatch", name="sensor_reboot_sms_batch", methods={"POST"})
*/
public function rebootSmsBatch(Request $request, WhereEverSmsService $sms, SmsLogger $smsLogger): JsonResponse
{
$this->denyAccessUnlessGranted('ROLE_SUPER_ADMIN');
$payload = json_decode($request->getContent(), true);
$ids = \is_array($payload['ids'] ?? null) ? $payload['ids'] : [];
$ids = array_values(array_unique(array_filter(array_map('intval', $ids))));
// Ohne Angabe bleibt es beim bisherigen Verhalten (Reboot)
$command = (string) ($payload['command'] ?? 'Reboot');
if (empty($ids)) {
return new JsonResponse(['state' => 'error', 'msg' => 'Keine Sensoren ausgewählt.']);
}
if (!isset(WhereEverSmsService::COMMANDS[$command])) {
return new JsonResponse(['state' => 'error', 'msg' => 'Unbekanntes Kommando: '.$command]);
}
if (\count($ids) > self::SMS_BATCH_MAX) {
return new JsonResponse(['state' => 'error', 'msg' => 'Zu viele Sensoren ausgewählt (max. '.self::SMS_BATCH_MAX.').']);
}
$repo = $this->getDoctrine()->getRepository(Sensor::class);
$sent = 0;
$failed = 0;
$results = [];
foreach ($ids as $id) {
$sensor = $repo->find($id);
if (null === $sensor) {
++$failed;
$results[] = ['id' => $id, 'ok' => false, 'error' => 'Sensor nicht gefunden'];
continue;
}
$r = $this->doCommandSms($sensor, $command, $sms, $smsLogger);
$ok = 'OK' === $r['state'];
$ok ? ++$sent : ++$failed;
$results[] = [
'id' => $id, 'ok' => $ok, 'status' => $r['status'] ?? null,
'smsLogId' => $r['smsLogId'] ?? null, 'smsMessageId' => $r['smsMessageId'] ?? null,
'error' => $ok ? null : ($r['msg'] ?? null),
];
}
return new JsonResponse([
'state' => 'OK',
'msg' => sprintf('%d gesendet, %d fehlgeschlagen', $sent, $failed),
'sent' => $sent, 'failed' => $failed, 'results' => $results,
]);
}
/**
* Sendet die Steuer-SMS, ermittelt den Sofort-Status und schreibt einen SMS-Log-Eintrag.
* Gemeinsame Logik von Einzel- und Batch-Versand. Das Kommando landet unverändert im
* Log (Feld messageText), damit später nachvollziehbar ist, was geschickt wurde.
*
* @return array{state: string, msg: string, smsMessageId: mixed, status: string|null, smsLogId: int|null}
*/
private function doCommandSms(Sensor $sensor, string $command, WhereEverSmsService $sms, SmsLogger $smsLogger): array
{
$sentBy = $this->getUser() ? (string) $this->getUser()->getUsername() : null;
$send = $sms->sendCommand($sensor, $command);
if (!$send['ok']) {
$log = $smsLogger->log($sensor, $command, null, 'Fehler', $sentBy);
return ['state' => 'error', 'msg' => $send['error'] ?? 'Versand fehlgeschlagen', 'smsMessageId' => null, 'status' => null, 'smsLogId' => null !== $log ? $log->getId() : null];
}
$status = null;
if (null !== $send['smsMessageId']) {
$detail = $sms->getSmsDetail($send['smsMessageId']);
$status = $detail['ok'] ? $detail['status'] : null;
}
$log = $smsLogger->log($sensor, $command, null !== $send['smsMessageId'] ? (string) $send['smsMessageId'] : null, $status, $sentBy);
return [
'state' => 'OK',
'msg' => $command.'-SMS gesendet'.(null !== $status ? ' (Status: '.$status.')' : ''),
'smsMessageId' => $send['smsMessageId'],
'status' => $status,
'smsLogId' => null !== $log ? $log->getId() : null,
];
}
/**
* Live-Nachziehen des Zustellstatus für konkrete SMS-Log-Einträge (per Log-ID) — vom Batch-Panel
* der Sensor-Liste gepollt, damit die Zustellung ohne Seitenreload erscheint. Fragt nur noch
* offene (Pending/Sent) Einträge bei Jasper nach; terminale Status kommen unverändert zurück.
*
* @Route("/sensor/smsStatusBatch", name="sensor_sms_status_batch", methods={"POST"})
*/
public function smsStatusBatch(Request $request, WhereEverSmsService $sms): JsonResponse
{
$this->denyAccessUnlessGranted('ROLE_SUPER_ADMIN');
$payload = json_decode($request->getContent(), true);
$logIds = \is_array($payload['logIds'] ?? null)
? array_values(array_unique(array_filter(array_map('intval', $payload['logIds']))))
: [];
if (empty($logIds)) {
return new JsonResponse(['state' => 'OK', 'results' => []]);
}
$em = $this->getDoctrine()->getManager();
$logs = $this->getDoctrine()->getRepository(SmsLog::class)->findBy(['id' => $logIds]);
$updated = false;
$results = [];
foreach ($logs as $log) {
$status = $log->getStatus();
$open = (null === $status || \in_array($status, SmsLogRepository::OPEN_STATUSES, true));
if ($open && null !== $log->getSmsMessageId()) {
$detail = $sms->getSmsDetail($log->getSmsMessageId());
if ($detail['ok'] && null !== $detail['status'] && $detail['status'] !== $status) {
$log->setStatus($detail['status']);
$status = $detail['status'];
$updated = true;
}
}
$results[] = ['logId' => $log->getId(), 'status' => $status];
}
if ($updated) {
$em->flush();
}
return new JsonResponse(['state' => 'OK', 'results' => $results]);
}
/**
* Manuelles Nachziehen des Zustellstatus für die offenen SMS-Logs einer TGA (Aufzug/NEA).
*
* @Route("/sensor/smsLogRefresh/{type}/{id}", name="sms_log_refresh", methods={"POST"}, requirements={"type"="elevator|nea"})
*
* @param mixed $id
*/
public function smsLogRefresh(string $type, $id, WhereEverSmsService $sms): JsonResponse
{
$this->denyAccessUnlessGranted('ROLE_SUPER_ADMIN');
$em = $this->getDoctrine()->getManager();
$repo = $this->getDoctrine()->getRepository(SmsLog::class);
$since = (new \DateTime())->modify('-2 hours');
$open = 'elevator' === $type
? $repo->findOpenForStatusRefresh(50, $since, (int) $id, null)
: $repo->findOpenForStatusRefresh(50, $since, null, (int) $id);
$updated = 0;
foreach ($open as $log) {
$detail = $sms->getSmsDetail($log->getSmsMessageId());
if ($detail['ok'] && null !== $detail['status'] && $detail['status'] !== $log->getStatus()) {
$log->setStatus($detail['status']);
++$updated;
}
}
if ($updated > 0) {
$em->flush();
}
return new JsonResponse(['state' => 'OK', 'msg' => $updated.' Status aktualisiert', 'updated' => $updated]);
}
/**
* Zustellstatus einer bereits gesendeten Reboot-SMS erneut abfragen (getSmsDetail).
*
* @Route("/sensor/smsStatusWherever/{sensor}/{smsMessageId}", name="sensor_sms_status_wherever", methods={"GET"})
*
* @param mixed $smsMessageId
*/
public function smsStatusWherever(Sensor $sensor, $smsMessageId, WhereEverSmsService $sms): JsonResponse
{
$this->denyAccessUnlessGranted('ROLE_SUPER_ADMIN');
$detail = $sms->getSmsDetail($smsMessageId);
if (!$detail['ok']) {
return new JsonResponse(['state' => 'error', 'msg' => $detail['error'] ?? 'Statusabfrage fehlgeschlagen']);
}
return new JsonResponse(['state' => 'OK', 'msg' => 'Status: '.($detail['status'] ?? 'unbekannt'), 'status' => $detail['status']]);
}
/**
* @Route("/sensor/update", name="sensor_update", methods={"GET"})
*
* @OA\Get(
* summary="Get new observer scripts",
*
*
* @OA\Parameter(
* name="body",
* description="Get data.",
* in="body",
* required=true,
*
* @OA\Schema(
*
* @OA\Property(
* property="serial",
* type="string",
* minLength=1,
* example=1
* ),
* @OA\Property(
* property="scriptType",
* type="string",
* minLength=1,
* example=1
* ),
* )
* ),
*
* @OA\Response(
* response=200,
* description="Returns status 200 and the modified contact.",
*
* @OA\Schema(
* type="object",
* properties={
*
* @OA\Property(property="id", type="integer"),
* }
* )
* ),
*
* @OA\Response(
* response=404,
* description="Returns status 404 if there is no contact with the given id."
* )
* )
*/
public function sensorUpdateAction(Request $request, ObserverDeployManifest $manifest)
{
$projectDir = $this->appKernel->getProjectDir();
$baseDir = $projectDir.'/bin/acobesmart_observer/';
$data = json_decode($request->getContent());
$scriptType = $data->scriptType ?? null;
$em = $this->getDoctrine()->getManager();
$sensor = isset($data->serial)
? $em->getRepository(Sensor::class)->findOneBy(['serial' => $data->serial])
: null;
if (!$sensor) {
return new JsonResponse(['error' => 'Sensor nicht gefunden'], 404);
}
// Manifest: treibt das Auto-Update auf dem Pi (safe_update.py).
if ('manifest' === $scriptType) {
return new JsonResponse($manifest->build());
}
// Rueckwaerts-Kompatible Aliase (alte Pi-Scripts nutzen feste scriptType-Namen).
$aliases = [
'aco_observer' => 'aco_observer.py',
'aco_db_worker' => 'aco_db_worker.py',
'do_restart' => 'do_restart.py',
'helper' => 'helper.py',
'heartbeat' => 'heartbeat.py',
'onlineCheck' => 'online_check.py',
'safe_update' => 'safe_update.py',
];
// Sonderfall Version: nicht Teil des Deploy-Manifests (separater .version-Handshake).
if ('version' === $scriptType) {
$filePathAbs = $baseDir.'.version';
} else {
// Alias -> relativer Pfad; sonst wird scriptType direkt als relativer Pfad
// interpretiert (neue Pi-Scripts liefern den Manifest-'path').
$relPath = $aliases[$scriptType] ?? (string) $scriptType;
$filePathAbs = $manifest->resolveDeliverable($relPath);
if (null === $filePathAbs) {
return new JsonResponse(['error' => 'Unbekannter scriptType'], 400);
}
}
if (!is_file($filePathAbs)) {
return new JsonResponse(['error' => 'Datei nicht vorhanden'], 404);
}
$fileContent = file_get_contents($filePathAbs);
$response = new Response($fileContent);
$response->headers->set('Cache-Control', 'private');
$response->headers->set('Content-type', 'text/plain');
$response->headers->set('Content-length', (string) strlen($fileContent));
return $response;
}
/**
* Gets a list of sensors.
*
* @Rest\Get("/sensorsQrPrint")
*/
public function sensorsQrPrintAction(Request $request)
{
$data = [];
$sortField = false;
$sortOrder = false;
$limit = 20;
$currentPage = 1;
$pagination = ($request->get('pagination') ? $request->get('pagination') : false);
if ($pagination) {
$limit = $pagination['perpage'];
$currentPage = $pagination['page'];
}
$sortField = ($request->get('sort') ? $request->get('sort')['field'] : 'updatedAt');
$sortOrder = ($request->get('sort') ? $request->get('sort')['sort'] : 'DESC');
$query = ($request->get('query') ? $request->get('query') : []);
$repository = $this->getDoctrine()->getRepository(Sensor::class);
// findByCompany($user, $states = false, $tankStates = false, $sensorState = false, $limitSort = [], $isInstalled = true)
$limitSortQuery = [
'sortField' => $sortField,
'sortOrder' => $sortOrder,
'filter' => $query,
'limit' => $limit,
'currentPage' => $currentPage,
];
$serializedSensors = [];
$senorsComplete = $repository->getUninstalled($limitSortQuery, true);
$pages = round($senorsComplete / $limit);
$sensors = $repository->getUninstalled($limitSortQuery, false);
foreach ($sensors as $sensor) {
$serializedSensors[] = $this->serializeSensors($sensor, true);
}
$data['data'] = $serializedSensors;
$data['meta'] = [
'page' => $currentPage,
'pages' => $pages,
'perpage' => $limit,
'total' => $senorsComplete,
'sort' => $sortOrder,
'field' => $sortField,
];
$response = new Response(json_encode($data), 200);
$response->headers->set('Content-Type', 'application/json');
return $response;
}
/**
* Server-side Datenquelle für die Sensor-Liste (KTDatatable): liefert {data, meta}.
* Spalten: id, serial, phone, sim_number, state/stateLabel, address (Aufzug/NEA).
*
* @Rest\Get("/sensorsList", name="api_sensors_list")
*/
public function sensorsListAction(Request $request)
{
$this->denyAccessUnlessGranted('ROLE_SUPER_ADMIN');
$limit = 20;
$currentPage = 1;
$pagination = $request->get('pagination') ?: false;
if ($pagination) {
$limit = (int) $pagination['perpage'];
$currentPage = (int) $pagination['page'];
}
$sortField = $request->get('sort') ? $request->get('sort')['field'] : 'id';
$sortOrder = $request->get('sort') ? $request->get('sort')['sort'] : 'DESC';
$query = $request->get('query') ?: [];
$repository = $this->getDoctrine()->getRepository(Sensor::class);
$limitSortQuery = [
'sortField' => $sortField,
'sortOrder' => $sortOrder,
'filter' => $query,
'limit' => $limit,
'currentPage' => $currentPage,
];
$total = $repository->getFilteredForList($limitSortQuery, true);
$pages = $limit > 0 ? (int) ceil($total / $limit) : 1;
$sensors = $repository->getFilteredForList($limitSortQuery, false);
// Jüngsten Reboot-SMS-Zustellstatus je angezeigtem Sensor nachladen (nur diese Seite).
$sensorIds = array_map(function ($s) { return $s->getId(); }, $sensors);
$latestSms = $this->getDoctrine()->getRepository(SmsLog::class)->latestBySensorIds($sensorIds);
$rows = [];
foreach ($sensors as $sensor) {
$elevator = $sensor->getElevator();
$nea = $sensor->getNea();
$address = null;
if (null !== $elevator && null !== $elevator->getAddress()) {
$address = $elevator->getAddress();
} elseif (null !== $nea && method_exists($nea, 'getAddress') && null !== $nea->getAddress()) {
$address = $nea->getAddress();
}
$addressStr = '—';
if (null !== $address) {
$addressStr = trim(trim(sprintf('%s %s, %s %s', $address->getStreet(), $address->getStreetNumber(), $address->getPlz(), $address->getLocation())), ' ,') ?: '—';
}
$labels = $sensor->getStateNames();
$sms = $latestSms[$sensor->getId()] ?? null;
$rows[] = [
'id' => $sensor->getId(),
'serial' => $sensor->getSerial(),
'phone' => $sensor->getPhone(),
'sim_number' => $sensor->getSimNumber(),
'state' => $sensor->getState(),
'stateLabel' => $labels[$sensor->getState()] ?? (string) $sensor->getState(),
'address' => $addressStr,
'elevatorId' => null !== $elevator ? $elevator->getId() : null,
'neaId' => null !== $nea ? $nea->getId() : null,
'smsStatus' => null !== $sms ? $sms['status'] : null,
'smsLogId' => null !== $sms ? $sms['logId'] : null,
'smsCommand' => null !== $sms ? $sms['command'] : null,
'smsAt' => (null !== $sms && !empty($sms['createdAt'])) ? date('d.m.Y H:i', strtotime((string) $sms['createdAt'])) : null,
];
}
$data = ['data' => $rows, 'meta' => [
'page' => $currentPage, 'pages' => $pages, 'perpage' => $limit,
'total' => $total, 'sort' => $sortOrder, 'field' => $sortField,
]];
$response = new Response(json_encode($data), 200);
$response->headers->set('Content-Type', 'application/json');
return $response;
}
/**
* Gets a list of sensors.
*
* @Rest\Get("/getSignalAndProvider/{tgaHash}")
*
* @param mixed $tgaHash
*/
public function getSignalAndProvider(Request $request, $tgaHash)
{
$repository = $this->getDoctrine()->getRepository(Sensor::class);
if ($sensor = $repository->findOneByTgaHash($tgaHash)) {
$signal = $this->sensorRemoteService->getSignalQuality($sensor);
$provider = $this->sensorRemoteService->getProvider($sensor);
$response = ['signalQuality' => $this->sensorRemoteService->signalQualityToString($signal).'('.$signal.')', 'provider' => $provider, 'error' => false, 'errorMsg' => ''];
} else {
$response = ['signalQuality' => '', 'provider' => '', 'error' => true, 'errorMsg' => 'Sensor not found!'];
}
return new JsonResponse($response);
}
/**
* Gets a list of sensors.
*
* @Rest\Get("/getSignal/{tgaHash}", name="get_signal")
*
* @param mixed $tgaHash
*/
public function getSignal(Request $request, $tgaHash)
{
$repository = $this->getDoctrine()->getRepository(Sensor::class);
if ($sensor = $repository->findOneByTgaHash($tgaHash)) {
$signal = $this->sensorRemoteService->getSignalQuality($sensor);
$response = ['signalQuality' => $this->sensorRemoteService->signalQualityToString($signal).'('.$signal.')', 'error' => false, 'errorMsg' => ''];
} else {
$response = ['signalQuality' => 'N/A', 'error' => true, 'errorMsg' => 'Sensor not found!'];
}
return new JsonResponse($response);
}
/**
* Is sensor online.
*
* @Rest\Get("/isOnline/{tgaHash}", name="is_online")
*
* @param mixed $tgaHash
*/
public function isOnline(Request $request, $tgaHash)
{
$repository = $this->getDoctrine()->getRepository(Sensor::class);
if ($sensor = $repository->findOneByTgaHash($tgaHash)) {
$isOnline = $this->sensorRemoteService->checkSSHConnection($sensor);
// updateRunning: laeuft gerade ein Software-Update? -> InstallTool zeigt "Update laeuft"
// statt "offline", damit der Monteur den Sensor nicht resettet. updateStatus: letzter
// Zustand (v.a. failed_network=Empfangsprobleme), falls die Seite erst nach dem Live-
// Event via Mercure geoeffnet/neu geladen wird.
$response = [
'isOnline' => $isOnline,
'updateRunning' => $sensor->isUpdateRunning(),
'updateStatus' => $sensor->getUpdateStatus(),
'errorMsg' => '',
];
} else {
$response = ['isOnline' => false, 'updateRunning' => false, 'updateStatus' => null, 'errorMsg' => 'Sensor not found!'];
}
return new JsonResponse($response);
}
/**
* Meldung der Update-Scripts (safe_update.py): "running" beim Start, "done"/"failed" am Ende.
* Setzt sensor.update_started_at und pusht das InstallTool-Live-Topic.
*
* @Route("/sensor/updateStatus", name="sensor_update_status", methods={"PUT"})
*/
public function updateStatusAction(Request $request)
{
$data = json_decode($request->getContent());
$em = $this->getDoctrine()->getManager();
$sensor = isset($data->serial)
? $em->getRepository(Sensor::class)->findOneBy(['serial' => $data->serial])
: null;
if (!$sensor) {
return new JsonResponse(['error' => 'Sensor nicht gefunden'], 404);
}
$status = $data->status ?? '';
$attempt = isset($data->attempt) ? (int) $data->attempt : null;
$maxAttempts = isset($data->max_attempts) ? (int) $data->max_attempts : null;
// running/retry -> Update laeuft: Timer setzen/verlaengern (isUpdateRunning -> "laeuft").
// done -> fertig, Feld raus. failed*/sonst -> Timer aus, aber Zustand bleibt persistent,
// damit ein spaeter geoeffnetes InstallTool den Ausgang (v.a. Empfangsprobleme) noch zeigt.
$running = in_array($status, ['running', 'retry'], true);
$sensor->setUpdateStartedAt($running ? new \DateTime() : null);
$sensor->setUpdateStatus('done' === $status ? null : ($status ?: null));
$em->flush();
try {
$this->hub->publish(new Update(
'https://acobesmart.com/sensorinstall_'.$this->appKernel->getEnvironment().'_'.$sensor->getId(),
json_encode(['type' => 'update', 'status' => $status, 'attempt' => $attempt, 'max' => $maxAttempts])
));
} catch (\Throwable $e) {
$this->logger->error('updateStatus mercure: '.$e->getMessage());
}
return new JsonResponse(['ok' => 'ok']);
}
/**
* Is sensor online.
*
* @Rest\Get("/clearSensorDatabase/{tgaHash}", name="clear_sensor_database")
*
* @param mixed $tgaHash
*/
public function clearSensorDatabase(Request $request, $tgaHash)
{
$repository = $this->getDoctrine()->getRepository(Sensor::class);
if ($sensor = $repository->findOneByTgaHash($tgaHash)) {
$cleared = $this->sensorRemoteService->clearSensorDatabase($sensor);
$this->sensorRemoteService->restartAllScripts($sensor);
$response = ['cleared' => $cleared, 'errorMsg' => ''];
} else {
$response = ['cleared' => false, 'errorMsg' => 'Error on clearing sensor DB.'];
}
return new JsonResponse($response);
}
/**
* Gets a list of sensors.
*
* @Rest\Get("/getProvider/{tgaHash}", name="get_provider")
*
* @param mixed $tgaHash
*/
public function getProvider(Request $request, $tgaHash)
{
$repository = $this->getDoctrine()->getRepository(Sensor::class);
if ($sensor = $repository->findOneByTgaHash($tgaHash)) {
$provider = $this->sensorRemoteService->getProvider($sensor);
$response = ['provider' => $provider, 'error' => false, 'errorMsg' => ''];
} else {
$response = ['provider' => 'N/A', 'error' => true, 'errorMsg' => 'Sensor not found!'];
}
return new JsonResponse($response);
}
private function serializeSensors(Sensor $sensor)
{
$serialized = [
'id' => $sensor->getId(),
'sensorId' => $sensor->getId(),
'createdAt' => $sensor->getCreatedAt()->format('d.m.Y H:i:s'),
'stateName' => $sensor->getStateName(),
'typeOfState' => $sensor->getTypeOfState(),
'lastOnline' => ($sensor->getLastOnline() ? $sensor->getLastOnline()->format('d.m.Y H:i:s') : 'N/A'),
'observerState' => $sensor->getObserverStateName(),
'typeOfObserverState' => $sensor->getTypeOfObserverState(),
'observerLastOnline' => ($sensor->getObserverLastOnline() ? $sensor->getObserverLastOnline()->format('d.m.Y H:i:s') : 'N/A'),
'observerVersion' => $sensor->getObserverVersion(),
'serial' => $sensor->getSerial(),
'port' => $sensor->getPort(),
];
$actionArray = [];
$actionArray['showSensorqrCode'] = $this->generateUrl('show_sensor_qr_coed', ['sensorHash' => $sensor->getTgaHash()]);
$serialized['actions'] = $actionArray;
return $serialized;
}
}